<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:atom="http://www.w3.org/2005/Atom"
>
<channel>
<title><![CDATA[拾音笔记]]></title> 
<atom:link href="https://blog.wb.sqyai.com/rss.php" rel="self" type="application/rss+xml" />
<description><![CDATA[语音合成、Android，以及一些折腾记录]]></description>
<link>https://blog.wb.sqyai.com/</link>
<language>zh-cn</language>

<item>
    <title>开博：把折腾过的东西记下来</title>
    <link>https://blog.wb.sqyai.com/?post=1</link>
    <description><![CDATA[<p>博客叫「拾音笔记」。拾音是取声音，也是把散落的东西捡回来——这两个意思我都需要。</p>
<p>最近大半年都在折腾一件事：让手机上的阅读类 App 能用一个听起来像人的声音，把文字念出来。为了这个目标，选型、框架、参数、听感，来来回回推翻了好几轮。很多结论当时觉得记住了，隔两周又要重新查一遍，所以干脆开个地方写下来。</p>
<h2>这里会写什么</h2>
<ul>
<li>语音合成引擎的选型与听感评估，尤其是中文长句、多音字、停顿这些容易翻车的地方；</li>
<li>Android TTS 框架的系统级接入，以及新版本上那些文档里不太写的行为；</li>
<li>顺手的建站、部署、运维记录——比如这个站本身是怎么在一台新机器上跑起来的。</li>
</ul>
<h2>这个站怎么搭的</h2>
<p>emlog Pro 2.6.36，跑在 OpenCloudOS 9 上，nginx 1.29 + PHP 8.3 + MariaDB 10.11，证书是 Let's Encrypt 签的，全程 HTTPS。没用面板的一键部署，主要是想把每一步都摸清楚，省得以后出问题不知道从哪儿查。具体踩了哪些坑，单独写了一篇。</p>
<p>文章不多，先放几篇，之后慢慢补。写错的、想过但没想明白的，也一并留在这儿。</p>]]></description>
    <pubDate>Mon, 09 Nov 2026 01:35:00 +0800</pubDate>
    <dc:creator>拾音</dc:creator>
    <guid>https://blog.wb.sqyai.com/?post=1</guid>
</item>
<item>
    <title>一台新机器上把 emlog 跑起来</title>
    <link>https://blog.wb.sqyai.com/?post=5</link>
    <description><![CDATA[<p>这台机器给过来的时候是干净的：OpenCloudOS 9.6，装了宝塔面板，但 nginx、PHP、数据库一个没起，<code>/www/wwwroot</code> 是空的。记录一下从零到能访问的几个坎。</p>
<h2>安装包名和别处不一样</h2>
<p>软件源里有 <code>nginx-core 1.29</code>、<code>php-fpm 8.3</code>、<code>mariadb-server 10.11</code>，但没有 <code>nginx</code> 和 <code>php</code> 这两个元包。<code>dnf install nginx php</code> 会直接报 <em>Unable to find a match</em>。逐包装才顺，顺带把 <code>php-mysqlnd</code>、<code>php-gd</code>、<code>php-mbstring</code>、<code>php-xml</code>、<code>php-pecl-zip</code> 补齐。</p>
<h2>nginx-core 不带 systemd unit</h2>
<p>最费时间的是这个：包装完 <code>systemctl start nginx</code> 回 <em>Unit nginx.service not found</em>，<code>nginx -t</code> 却是好的。原因是服务文件在另外的包里。自己补一个 unit 最省事，用 <code>Type=simple</code> 加 <code>nginx -g 'daemon off;'</code>，连 PIDFile 都不用纠结。</p>
<h2>nginx 和 php-fpm 要同一个用户</h2>
<p>默认 php-fpm 跑 <code>apache</code>，而这里 nginx 跑 <code>nginx</code>。站点目录、上传目录、session 目录的写权限会互相打架。把 <code>www.conf</code> 里的 user/group 一起改成 <code>nginx</code>，再把 fastcgi 走 <code>127.0.0.1:9000</code>，比调 unix socket 的 ACL 省心。</p>
<h2>安装向导提交时别忘了 Host 头</h2>
<p>emlog 的 <code>install.php?action=install</code> 是标准表单，可以用 curl 无人值守提交。但它会把 <code>BLOG_URL</code> 写成当次请求的域名——如果图省事用 <code>http://127.0.0.1/install.php</code> 提交，以后所有链接都会指到 127.0.0.1。正确做法是带 <code>-H "Host: 你的域名"</code>，装完再核对一遍数据库里 <code>blogurl</code> 的值。</p>
<h2>最后两步</h2>
<p>建库时直接指定 <code>utf8mb4</code>；证书用 certbot 的 webroot 模式签，80 端口只留 <code>/.well-known/acme-challenge/</code> 给续期用，其余 301 到 HTTPS。装完记得确认 <code>install.php</code> 已被删掉（向导会自己删），以及清一次 <code>content/cache/</code> 下的缓存，否则改过的站点配置不会立刻生效。</p>]]></description>
    <pubDate>Mon, 09 Nov 2026 00:50:00 +0800</pubDate>
    <dc:creator>拾音</dc:creator>
    <guid>https://blog.wb.sqyai.com/?post=5</guid>
</item>
<item>
    <title>为什么最后把引擎定在 MiMo TTS</title>
    <link>https://blog.wb.sqyai.com/?post=2</link>
    <description><![CDATA[<p>选型这件事，最后让我下决心的不是跑分，是同一段文字反复听了几十遍之后，哪一遍我忘了自己在听机器。</p>
<h2>我实际比较了什么</h2>
<p>参数表上的 MOS 分、采样率、模型规模，参考价值有限，因为最终场景是「听一整章小说」，不是念一句「今天天气不错」。我把比较收窄到四个地方：</p>
<ol>
<li><strong>长句的呼吸感</strong>：三十字以上的句子，逗号处停多久、有没有不该断的地方断了。</li>
<li><strong>多音字和专名</strong>：「行长」「重庆」这类，以及人名、书名里不常见的字。</li>
<li><strong>情绪的平</strong>：朗读最怕每句都带一点播音腔，听二十分钟会累。</li>
<li><strong>变速之后还像不像人</strong>：这一条权重最高，因为产品里语速是要交给用户调的。</li>
</ol>
<h2>怎么听才不算自欺欺人</h2>
<p>固定一段三千字左右的文本，一半叙事一半对话；同一副耳机，同一音量；把不同引擎的输出切成十段打乱顺序，隔天再听一遍，看还能不能分出来哪个是哪个。第一次能分出来不算本事，第二天还能分出来，差别才是真的。</p>
<p>MiMo 在这套听法下最明显的优势是句尾不飘、长句里停顿的位置比较克制，变速到 1.3 倍也不会出现那种「赶着念完」的挤压感。短板也有：极短的单句有时略显平，需要靠前端的文本归一化和标点处理补一下。</p>
<blockquote>选引擎别只看单句 demo，拿你真实要读的内容去听，而且要在你打算让用户用的那个语速上听。</blockquote>
<h2>还有一层约束</h2>
<p>因为要做的是能被系统阅读类 App 调用的引擎，不是自带播放器的独立应用，所以「声音好不好」只是入场券。还得看它能不能稳定地按 Android TTS 框架的回调方式把音频流吐出来、能不能在合成过程中响应中断、以及语速参数能不能落到底层。这部分后面单独写。</p>]]></description>
    <pubDate>Fri, 06 Nov 2026 20:30:00 +0800</pubDate>
    <dc:creator>拾音</dc:creator>
    <guid>https://blog.wb.sqyai.com/?post=2</guid>
</item>
<item>
    <title>把 App 接进 Android 16 的 TTS 框架</title>
    <link>https://blog.wb.sqyai.com/?post=3</link>
    <description><![CDATA[<p>做独立播放器，是自己想怎么读就怎么读；做系统 TTS 引擎，是别人（阅读 App）说了算，你只负责把活接住。这个区别决定了整个结构。</p>
<h2>最小的骨架</h2>
<p>核心是继承 <code>TextToSpeechService</code>，在 manifest 里声明服务，并让系统能在「文字转语音输出」设置里找到它：</p>
<pre><code>&lt;service android:name=".MimoTtsService"
         android:label="@string/engine_name"&gt;
    &lt;intent-filter&gt;
        &lt;action android:name="android.intent.action.TTS_SERVICE" /&gt;
    &lt;/intent-filter&gt;
    &lt;meta-data android:name="android.speech.tts"
               android:resource="@xml/tts_engine" /&gt;
&lt;/service&gt;</code></pre>
<p><code>xml/tts_engine</code> 里指向一个 settings Activity，用来放语速、音色这些开关。系统设置页会直接拉起它。</p>
<h2>三个必须实现的方法</h2>
<ul>
<li><code>onIsLanguageAvailable(lang, country, variant)</code>：别偷懒一律返回 <code>LANG_AVAILABLE</code>，中文简繁、中英混排的判断要在这里说清楚，不然阅读 App 会误判可用性。</li>
<li><code>onGetVoices()</code> / <code>onGetDefaultVoiceNameFor()</code>：较新版本靠这个列音色，返回空列表会让部分客户端直接禁用引擎。</li>
<li><code>onSynthesizeText(request, callback, params)</code>：真正干活的地方，用 <code>SynthesisCallback</code> 依次 <code>start()</code> → <code>audioAvailable(byte[])</code> → <code>done()</code>。</li>
</ul>
<pre><code>override fun onSynthesizeText(
    request: SynthesisRequest,
    callback: SynthesisCallback,
    params: Bundle
) {
    val rate = request.speechRate   // 客户端 setSpeechRate() 传进来的
    callback.start(SAMPLE_RATE, AudioFormat.ENCODING_PCM_16BIT, 1)
    engine.stream(request.charSequenceText.toString(), rate) { chunk -&gt;
        if (!callback.isDone) callback.audioAvailable(chunk, 0, chunk.size)
    }
    callback.done()
}</code></pre>
<p>几个容易踩的点：<code>audioAvailable</code> 要在合成线程里持续喂，别等整段合成完一次性给，长章节会明显感到起播慢；<code>onStop()</code> 必须真的把合成任务取消掉，翻页时系统会频繁调用；回调期间要检查 <code>callback.isDone</code>，否则中断后再写会抛异常。</p>
<h2>Android 16 上要多留意的</h2>
<p>targetSdk 提到新版本后，后台启动和前台服务的限制更严，长时间朗读别依赖一个常驻后台服务自己保活，跟着系统的合成生命周期走更稳。另外 <code>SynthesisRequest</code> 里能拿到的参数比想象中少，语速、音调之外不要指望客户端把自定义字段透传过来——真要扩展，走自己的 settings 加共享配置更现实。</p>
<p>调试时最省事的办法，是写一个只调 <code>TextToSpeech</code> 并显式指定引擎包名的小测试 App，比在系统设置里来回切引擎快得多。</p>]]></description>
    <pubDate>Wed, 04 Nov 2026 22:15:00 +0800</pubDate>
    <dc:creator>拾音</dc:creator>
    <guid>https://blog.wb.sqyai.com/?post=3</guid>
</item>
<item>
    <title>语速可调，比想象中麻烦</title>
    <link>https://blog.wb.sqyai.com/?post=4</link>
    <description><![CDATA[<p>「加个语速滑条」听起来是半小时的活。真做完，前后改了三版。</p>
<h2>第一版：直接把 float 透传下去</h2>
<p>Android 的 <code>TextToSpeech.setSpeechRate(float)</code> 传进来是个浮点，1.0 是标准。第一版我原样交给引擎，结果 0.7 以下声音开始发闷，1.6 以上辅音糊成一团——底层做的不变速变调，而引擎内部其实只认几个离散档位，中间值是被插值出来的。</p>
<h2>第二版：分段映射 + 边界保护</h2>
<p>把用户可选范围收到 0.75～1.5，按档位映射，并在两端留一点余量：</p>
<pre><code>fun mapRate(ui: Float): Float = when {
    ui &lt; 0.9f -&gt; 0.8f
    ui &lt; 1.1f -&gt; 1.0f
    ui &lt; 1.3f -&gt; 1.2f
    else       -&gt; 1.5f
}</code></pre>
<p>听感上，档位之间有一点跳，但每一档都是干净的。朗读场景里，用户其实记不住 1.13 和 1.17 的区别，能记住的是「稍慢」「正常」「稍快」。</p>
<h2>第三版：不给用户裸滑条</h2>
<p>最后 UI 改成四个预设加一个高级里的连续滑条。原因不是技术，是使用场景：听小说时人在走路、做饭、开车，没人会停下来精细调 0.05 倍。预设一按就走，滑条留给少数真的想调的人。</p>
<p>还有两个细节：切换语速要作用在「下一句」而不是当前正在播的这一句，否则会听到一声明显的拼接；以及语速设置要跟着引擎一起持久化，阅读 App 每次新建 <code>TextToSpeech</code> 实例时并不会替你记住上次的值。</p>
<blockquote>可调语速的价值不在「能调」，在「调完之后仍然像在听人念书」。</blockquote>]]></description>
    <pubDate>Mon, 02 Nov 2026 19:45:00 +0800</pubDate>
    <dc:creator>拾音</dc:creator>
    <guid>https://blog.wb.sqyai.com/?post=4</guid>
</item>
</channel>
</rss>