
简介freeTTS是一套基于Java的开源文本转语音工具包面向需要为桌面或服务端应用加入语音合成能力的开发者可广泛用于语音助手、教育软件、无障碍技术等场景。包内共103个文件整体约3.75MB包含Java源码、配置脚本、语音文本样例、测试用utt文件以及指标记录与说明文档等目录结构保留META-INF与freetts-1.2.1约定布局便于直接理解工程组成。对学习者而言可通过其中的脚本与样例快速体验音速、音高、语调等合成参数调整并梳理JSAPI、Festival TTS等发音引擎的接入方式。包内还包含文本处理与事件驱动API的示例实现可帮助理解合成器、发音选择、文本处理等核心模块的协作关系并结合测试脚本验证不同音频输出效果。资源已有800人学习下载适合希望研究TTS内部机制、实现自有语音功能或二次定制的Java开发者。1. freeTTS在云端合成与录音文件之间补上“JVM 内离线发音”这一格第一次听到 freeTTS 的声音它就像一个卡了壳的电子词典机械、发闷、毫无情绪。但后来我在一台完全内网的设备上需要语音提示时反而把它当成了首选freeTTS 是用纯 Java 写成的语音合成引擎文本进、音频出合成过程全在 JVM 里完成不碰网络、不依赖云接口、也没有一堆动态库需要伺候。对 Java 技术栈的开发者来说这是评估离线播报时最顺手的候选方案适合的场景是设备状态播报、桌面工具提示、自动化测试里的音频链路验证。如果你指望它读大段中文或自然得像真人播音趁早换方向但如果你要的是一句稳定可控、随时能嵌入代码的英文播报它值得你先花半小时跑通再说。2. 合成管线与音源选型为什么 freeTTS 听起来像机器人freeTTS 的合成方式归根结底一句话双音素拼接。系统先把输入文本拆成词再查发音词典把每个词变成一串音素接着按相邻音素的组合把音库里预先录好的小片段一段段拼起来最后经过时长和音高的微调输出成连贯的波形。听起来没什么问题但这套机制的瓶颈在“拼”字上每个片段都有自己的相位、能量和共振峰走向拼接处的频谱断裂会被耳朵放大于是就有了我们听到的电子味和机械感。这不是参数没调好也不是什么玄学而是拼接型合成器的天然边界。从输入文本到最终出声freeTTS 内部大致走三步。第一步是文本分析切词、标注词性、查发音词典把“welcome”变成一串带重音标记的音素第二步是韵律生成根据标点和词性预测每个位置的停顿时长、音高走向第三步才是波形合成把处理好的音素序列送去和音库里的双音素片段做匹配拼接。这三步全是确定的规则计算没有任何统计模型的“概率输出”所以同一条文本每次合成的波形几乎一致行为高度可预期。对工程而言可预期是一件比音色更值钱的事——你可以在测试环境还原任何一次播报而不是像黑匣子一样只能听结果。2.1 三套音源怎么选默认 kevin16而不是 kevinfreeTTS 自带几个英文男声音源其中最容易被新手选错的是 kevin。kevin 的采样率只有 8kHz这是早年电话语音的带宽标准高频信息基本被砍光英文里的 /s/、/f/、/t/ 尾音会糊成一团。同样是 hellokevin 听起来像蒙着被子说的kevin16 就能听清尾音的气流声。所以我的建议很简单默认用 kevin16。alan 是另一档 16kHz 音源在 kevin16 的基线上做了高频润色语速听感略慢一点点适合播报整句较长、希望少一点“尖锐感”的提示语。音源名采样率听感特征使用建议kevin8 kHz闷、糊、像电话音不推荐kevin1616 kHz清晰、带轻微机械感默认选择alan16 kHz圆润、尾音更稳长句播报选音源时不要只看参数表同一句话换三个音源试听会更可靠。我一般用 It’s a test 这句话对比重点听结尾 /t/ 的气流声是否干净、整体有没有明显的底噪。如果你在安静环境里放一遍能明显听出 kevin16 比 kevin 亮一截那就说明选型方向对了。还有一点需要提前说明这些音源内置在 jar 包里不额外下载语音数据这既是优点也是边界——你不能像商业引擎那样去“下载一个女声音色包”音色是锁死的。2.2 一次 allocate多次 speakfreeTTS 的正确驻留方式Voice 是 freeTTS 里真正干活的类但别把它当成一个轻量对象。调用 allocate() 时freeTTS 会把发音词典、双音素片段库和时长模型全部载入内存并完成声学参数的初始化这个过程受磁盘和内存影响通常在几秒到十几秒之间。所以正确做法是把 Voice 设计成进程级单例启动时 allocate 一次之后反复 speak。如果偷懒每次播报前才 getVoice 再 allocate播完马上 deallocate你会看到明显的启动抖动内存里反复加载卸载GC 时间直线上升。也正因如此freeTTS 不太适合“播一句就退出”的一次性命令行工具它更适合常驻进程。内存账也可以提前算清楚16kHz、16bit、单声道的裸音频数据速率是每秒 32KB一个 10 秒的句子在内存里缓冲也就 320KB这点压力对现代应用微乎其微。但要注意的是把超长文本一次性丢给 speak输出缓冲会随文本长度线性上涨所以长文本应当切句分批合成而不是一次性灌进去。线程安全也需要提前声明官方文档没有承诺 Voice 是线程安全的这就意味着在同一时刻同一个 Voice 只应该被一个线程用来说话否则轻则卡顿重则直接爆音这个坑我们在后面避坑章节专门展开。2.3 CPU 与内存画像它凭什么在离线设备上站稳freeTTS 比云端方案轻根本原因是它不需要跑深度学习模型。每次合成就是查表、拼接、加一段时长调制CPU 开销非常有限在低配设备上也能保证实时合成速度。启动之后的内存占用主要由音库大小决定语料全部常驻 JVM不再需要额外读取运行时额外产生的只是一小段输出缓冲。这种“启动重、运行轻”的特征决定了它适合被放进一个长时间运行的进程里而不是被频繁创建销毁。这意味着选型时要有一个清醒判断如果你要的是高并发、低延迟、自然音色freeTTS 不是对手但如果你要的是一个进程里随时能调用、不产生网络流量、行为可复现的英文播报能力它比接云服务更省心。很多团队拿 freeTTS 和商业云端 TTS 对比音质后直接放弃这是把选型标准搞错了——离线可控本身就是它的核心价值。3. 最小 Java 用例从出声到写 WAV 文件的完整路径3.1 最小用例先让 kevin16 开口说话接入 freeTTS 时我建议直接从 VoiceManager 这个入口走而不是老的 JSAPI 接口。JSAPI 那套 javax.speech 包在 JDK 9 之后已经从标准库里移除了需要额外引入单独的依赖报错时还多一层抽象调试成本高VoiceManager 是 freeTTS 自己的 API路径短、调用直接出问题也在自己可控范围内。import com.sun.speech.freetts.Voice; import com.sun.speech.freetts.VoiceManager; public class FreeTtsDemo { public static void main(String[] args) { VoiceManager vm VoiceManager.getInstance(); Voice voice vm.getVoice(kevin16); if (voice null) { throw new IllegalStateException(kevin16 voice not found); } voice.allocate(); voice.speak(Welcome to offline speech synthesis.); voice.deallocate(); } }这段代码按四步走VoiceManager.getInstance() 拿到全局管理器getVoice 按名称取音源对象allocate 加载语料speak 开始合成并播放。speak 是阻塞调用这句话念完才返回最后 deallocate 释放资源。缺了 allocate 直接 speak会得到空指针或 IllegalStateException因为内部音库尚未初始化getVoice 返回 null第一件事不是改代码而是确认你用的 jar 里真的带 kevin16 这组音源数据有些瘦身过的发行版会砍掉部分音源。运行命令很简单把 freetts 的 jar 放进类路径就行javac -cp freetts.jar:. FreeTtsDemo.java java -cp freetts.jar:. FreeTtsDemofreetts.jar 对应当前环境里的实际包名请用你下载到的那个文件。如果你在 Maven 或 Gradle 项目里把这个 jar 安装进私有仓库作为普通依赖即可也可以直接扔到 lib 目录。跑起来后如果本地默认声音设备可用就能听到声音如果没声音不要急着怀疑代码看下一节改成文件输出再验证。3.2 无声卡环境合成结果直接落成 WAV 文件服务器上跑 freeTTS最常见的初步体验是程序没报错但没声音。这不怪 freeTTSJava Sound 默认找系统声卡而服务器常常没有。与其去解决声卡不如直接把合成的 PCM 数据交给文件输出这比折腾音频驱动更可靠。import com.sun.speech.freetts.Voice; import com.sun.speech.freetts.VoiceManager; import com.sun.speech.freetts.audio.SingleFileAudioPlayer; import com.sun.speech.freetts.audio.SingleFileAudioPlayer.Format; public class FreeTtsToWav { public static void main(String[] args) throws Exception { VoiceManager vm VoiceManager.getInstance(); Voice voice vm.getVoice(alan); voice.allocate(); SingleFileAudioPlayer player new SingleFileAudioPlayer(hello_out, Format.WAVE); voice.setAudioPlayer(player); voice.speak(Hello from a server without sound card.); player.close(); voice.deallocate(); } }这里有两个容易踩的细节。一是文件名 hello_out 不带扩展名格式由 Format.WAVE 声明不要自己拼成 hello_out.wav 传进去否则会得到 hello_out.wav.wav二是 speak 结束后必须调用 close()它会回头把 WAV 文件头和帧长度补齐漏了这步生成的文件很可能被播放器判为损坏。跑完后当前目录会出现 hello_out.wav用 file 命令看一眼输出确认是 RIFF/WAVE 格式再交给下游播放。file hello_out.wav如果你后面要接自己的播放链路这套文件输出还能当作临时调试手段先确认 freeTTS 本身能正常出音频再排查你的播放设备问题定位会清晰很多。我一般会先跑一次这个用例把“引擎能不能出数据”和“我的设备能不能播”这两件事拆开避免两头猜。3.3 JDK 版本Java 8 最顺新版别硬扛freeTTS 出生在 JDK 1.4 时代jar 里没有 module-info.class顶层包名又带着 com.sun 前缀这个组合在 JDK 9 之后的模块系统下非常难受。第一次在 JDK 17 上跑ClassNotFoundException 报得干干净净加上一堆模块开放参数仍然不踏实后来干脆固定用 JDK 8 跑。这不是能力问题是兼容性账算不过来团队里如果有多个版本混用这条一定要提前写进约束。JDK 版本踩坑程度建议JDK 8基本无障碍本地与生产首选JDK 11类路径模式可以跑可用但别引入模块化配置JDK 17需要额外放行模块访问不建议在核心链路依赖更具体的处理方法是项目里不要写 module-info.java把 freetts.jar 放到 classpath 而不是 modulepath如果你的构建工具强制模块化可以把它打进 fat-jar用 java --class-path 直接启动。JDK 17 下就算能启动通常还会伴随关于 java.lang 内部类访问的 IllegalAccessError需要借助 --add-exports 逐个放行版本之间差异还很大维护成本远高于它带来的收益。实际项目里我倾向于在构建配置里把 JDK 版本锁在 8既省心又避免线上环境差异。4. 英文播报的参数调节与文本预处理让机械声音“能用”4.1 语速、音高、音量在机械边界内做微调freeTTS 能调的参数不多但把这几项调到合适区间听感能从“劝退”变成“可接受”。常用的四个参数都在 Voice 上有对应的 settersetRate 控制语速setPitch 控制基频setPitchRange 控制音高起伏范围setVolume 控制输出增益。voice.setRate(160); // 默认大约 150提示语可以稍快 voice.setPitch(100); // 默认男声偏高降到 100 左右更沉稳 voice.setPitchRange(18); // 音高起伏范围调大一点减少呆板感 voice.setVolume(1.0f); // 0.0 ~ 1.0 voice.speak(Set rate and pitch before speaking.);这组方法在 speak 之前调用即可不需要重新 allocate。调参时要一次只动一个变量先固定语速把 pitch 从 130 往下降到 90听听哪个点不闷、也不至于变成“气声”再调 pitchRange从 8 往上升到 20找到语调起伏自然、又不会像朗读腔的区间。凡是听到“嗡嗡”或者尾音颤抖基本都是 pitch 和 pitchRange 叠加过了头回退一档就好。参数setter常用范围效果语速setRate120~180 wpm越低越慢基频setPitch90~130 Hz越高越尖音高起伏setPitchRange8~20越大语调变化越明显音量setVolume0.0~1.0输出增益记住边界这些参数只能微调韵律不能改变音色。diphone 音库的音色在录制时就已经固定想从“机器人”变成“真人声”是模型级问题不是参数级问题。预期管理比调参本身更重要把目标定成“稳定、清楚、不刺耳”就好追求动听就选错工具了。4.2 数字、缩写与标点先把文本变成它“读得准”的形式freeTTS 的数字展开、缩写读法都按英文规则走。直接 speak(2024-08-15)它会按单个数字念出来完全没有日期的概念听感很滑稽。处理办法是自己写预处理把文本转成 freeTTS 读得准的形式。下面这个最小展开器覆盖了播报里最常出现的 1 到 99生产环境可以按业务继续扩展。private static final String[] SMALL { zero, one, two, three, four, five, six, seven, eight, nine, ten, eleven, twelve, thirteen, fourteen, fifteen, sixteen, seventeen, eighteen, nineteen }; private static final String[] TENS { , , twenty, thirty, forty, fifty, sixty, seventy, eighty, ninety }; public static String numberToWords(int n) { if (n 20) return SMALL[n]; if (n 100) { int tens n / 10; int ones n % 10; return TENS[tens] (ones 0 ? - SMALL[ones] : ); } return Integer.toString(n); // 三位以上按业务规则展开 } String spoken The package arrived on numberToWords(23) minutes ago.; voice.speak(spoken);这套逻辑虽然简单但已经把数字变成纯英文词序列freeTTS 不需要再猜。三位数、月份、年份、二十四小时制时间都可以照同样思路继续叠加规则。关键原则是freeTTS 不负责理解语义它只负责把你给的字符串读出来因此入参前的规范化越彻底听感越好。缩写同理URL、ID、API 这些词直接丢进去会被当成普通单词逐个字母拼读。我一般会先做一个业务词典用 Map 或 properties 文件把术语全部展开成完整发音形式再交给 speak。这是整个接入过程中最值得花时间的部分文本规范化做好了听感上限能立刻上一个台阶而且这套规则跟你换不换合成引擎都通用属于一次投入长期收益的活。4.3 接自己的音频链路实现一个最小 AudioPlayerfreeTTS 默认把音频交给 Java Sound 播放但嵌入场景里你往往需要把 PCM 数据交给蓝牙、网络、内部混音器而不是本地声卡。做法是替换 AudioPlayer这个接口方法很多核心只有几个。实现一个能接住 PCM 字节流的自定义播放器就能彻底绕开系统声卡依赖。import com.sun.speech.freetts.audio.AudioPlayer; import java.io.ByteArrayOutputStream; public class ByteArrayAudioPlayer implements AudioPlayer { private final ByteArrayOutputStream buf new ByteArrayOutputStream(); Override public void begin(int size) { buf.reset(); } Override public void write(int b) { buf.write(b); } Override public void write(byte[] data) { buf.write(data, 0, data.length); } Override public void write(byte[] data, int off, int len) { buf.write(data, off, len); } Override public void close() { byte[] pcm buf.toByteArray(); // 这里把 pcm 数据交给下游写入文件、推流、或交给硬件 } Override public void drain() {} Override public void reset() {} Override public void cancel() {} Override public void pause() {} Override public void resume() {} Override public void start() {} Override public void stop() {} Override public long getTime() { return 0; } Override public void setDuration(long l) {} Override public float getVolume() { return 1.0f; } Override public void setVolume(float v) {} Override public void showMetrics() {} }用法一句话voice.setAudioPlayer(new ByteArrayAudioPlayer())随后的 speak 会把音频字节逐步推给 writeclose() 时整段交接。空实现的方法是为了保证编译通过真正会被回调的是 begin、write 系列和 close如果需要暂停继续功能再去实现 pause/resume。到这一步freeTTS 就从一个“读给你听的播放器”变成了“给你音频数据的合成器”能玩的花样多很多。5. freeTTS 避坑五条血泪经验这一章的坑我基本都在生产环境里踩过一遍有的坑连文档里都查不到。每条按现象、原因、解决的顺序写方便你直接对照排查。5.1 JDK 17 下找不到 VoiceManager现象编译正常运行时抛 ClassNotFoundException指向 com.sun.speech.freetts.VoiceManager。原因freeTTS 的 jar 没有模块化声明包名又带 com.sun 前缀JDK 9 之后的模块系统默认不开放。解决给项目加 module-info 等于自找麻烦最简单是把 JVM 版本锁在 JDK 8如果公司强制新版用类路径模式运行并准备接受后续模块访问报错。java -cp freetts.jar:./lib/*:target/classes com.example.Main这条命令确保 freeTTS 的 jar 都在 classpath 上而不是被构建工具塞进 modulepath。我踩过的最深一次是项目里 A 模块引入了模块化配置B 模块再用 classpath 模式跑结果两个模块互相冲突排查了一整个下午。结论很简单要么全项目不模块化要么就老老实实换新引擎别在中间状态耗着。5.2 Linux 服务器上 speak 不返回现象allocate 成功speak 不报错但也不结束程序卡在那里。原因Java Sound 在无音频设备的机器上播放线程拿不到设备阻塞在混音器等待。解决先诊断有没有声卡。cat /proc/asound/cards如果输出基本是空的就别走默认播放路线改用 SingleFileAudioPlayer 写文件或者用自定义 AudioPlayer 拿 PCM 数据自己处理。还有一个隐蔽变体有些容器里声卡设备存在但权限不够表现同样是卡住这时候用文件输出也能绕开。判断的通用原则是freeTTS 的合成不需要声卡只有默认播放需要卡在 speak 上先怀疑输出设备不要怀疑合成引擎。5.3 第一句话卡 3 秒后面突然变快现象启动后第一次播报明显延迟人在设备前等了半天才出声。原因语料索引和内部缓存是懒加载的allocate 只完成了部分准备第一次 speak 才把双音素索引真正构建起来。解决启动时预热。voice.allocate(); voice.speak(ok); // 预热触发内部索引构建值得注意的细节是预热文本不能太短空字符串可能不触发完整构建流程。用 “ok” 这种词有实际内容但不会产生长音频预热效果够用。这招在报警系统里尤其关键因为报警往往就是进程起来后的第一声卡顿三秒等于报警延迟三秒。5.4 中文文本说一半、吞一半现象中英混排的句子英文正常播报中文要么静默跳过要么带出一个奇怪音效。原因freeTTS 的发音词典和音素集只覆盖英语不认识汉字遇到不认识的词默认跳过。解决中文短语用拼音替换再喂给引擎。MapString, String cn2py new HashMap(); cn2py.put(您好, nin hao); cn2py.put(启动成功, qi dong cheng gong); String input cn2py.getOrDefault(original, original); voice.speak(input);这个方案只适合固定短语覆盖率完全靠词典维护。如果团队的目标是大词汇量中文播报选型阶段直接换支持中文语料库的引擎更现实不要试图在 freeTTS 上打补丁。我见过有项目硬做了分词和拼音映射维护成本高不说多音字问题根本绕不过去最后撑了一年还是换了方案。5.5 多线程同时 speak声音叠加、爆音现象两个线程同时调用同一个 Voice 的 speak声音叠加在一起还伴随周期性“咔咔”爆音。原因Voice 内部有共享的合成状态当前语调、缓冲区位置、帧对齐都可能冲突官方没承诺线程安全speak 不可重入。解决全局锁住同一个 Voice把所有播报串行化。private final Object speakLock new Object(); public void safeSpeak(String text) { synchronized (speakLock) { voice.speak(text); } }这个锁要覆盖所有播报入口不能只锁业务层。最典型的一次翻车场景系统里报警模块和日志播报模块各自加了自己的锁两个锁锁的不是同一个对象结果依旧叠音。正确做法是让所有模块共享同一个 SpeechService 实例由它内部统一持锁一堆爆音问题立刻消失。6. 进阶把 freeTTS 做成可打断的离线播报服务6.1 单线程播报队列与“插队”机制把合成集中到一个线程上是 freeTTS 接入工程里最重要的一次重构同一个 Voice 只属于一条线程广播、报警、提示全都作为任务提交进去。打断需求用 Future.cancel 大概率不生效因为 speak 内部阻塞不一定响应中断。我常用的做法是维护一个 AtomicBoolean 的 cancel 标志由自定义 AudioPlayer 的 write 方法检查它一旦为 true 就停止写音频数据speak 虽然还在底层运行但人已经听不到了效果等于打断。public class SpeechService { private final Voice voice; private final AtomicBoolean cancel new AtomicBoolean(false); private final ExecutorService executor Executors.newSingleThreadExecutor(); public SpeechService() { VoiceManager vm VoiceManager.getInstance(); voice vm.getVoice(kevin16); voice.allocate(); } public void speak(String text) { executor.submit(() - { voice.setAudioPlayer(new CancelablePlayer(cancel)); voice.speak(text); }); } public void cancelCurrent() { cancel.set(true); } }CancelablePlayer 在 write 时检查 cancel为 true 就丢弃后续数据。关键点是 cancel 标志需要在每次播报开始时重置否则第一次取消后后面所有语音都会被静音。如果打断频繁不建议用 deallocate 再 allocate 的方式重置因为重新加载音库的延迟比打断本身还长得不偿失。6.2 上线前预热与自检freeTTS 的第一次合成比后续慢很多音库索引和内部缓存在首次使用时才初始化。线上播报不能等到报警发生那一刻才做第一次合成。我的习惯是启动时用一个短句预热并把合成结果接到内存缓冲区做长度校验确认 PCM 大于 0 字节才把服务标记为就绪。ByteArrayOutputStream probe new ByteArrayOutputStream(); voice.setAudioPlayer(new ByteArrayAudioPlayer()); voice.speak(ready); voice.setAudioPlayer(null); if (probe.size() 0) { throw new IllegalStateException(TTS self-test failed); }这个自检能同时暴露音库缺失、音频链路没接好、文件句柄未释放等问题把它放进启动流程里和数据库连接检查放在同一层比在监控里捞日志可靠得多。我之前在某个报警提示项目上线验收时当场翻车第一次报警触发时系统才加载语料提示音晚了整整两秒报警价值大打折扣。后来把预热和自检写死进启动流程连续运行几个月再没出现过一次晚播数据准备和工作分配一样如果不在启动时做完就得在关键时刻还账。希望帮到你。本文还有配套的精品资源点击获取