ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用摩斯密码报时:安卓音频合成与后台调度实践

用摩斯密码报时:安卓音频合成与后台调度实践 最近有一段时间我每晚都把短波收音机调到某个频段听夹杂在遥远的杂音里的电报信号那种“滴滴答答”的节奏让人上瘾。听多了以后我开始琢磨一件事如果手机不再用响声铃声报时而是用摩斯密码这种“滴滴答答”的节奏把当前时间说出来会是什么体验这个念头直接催生了一个安卓报时应用在到了整点或者用户指定的分钟数时用一长一短两种声音信号把“14:30”播成“ .---- ---.. / ...-- ----- ”这样的点划序列。用户听熟了闭着眼也能判断出现在几点。这篇文章就把这个小项目从想法到编码、音频、后台调度、再到调音的完整过程讲清楚适合所有对安卓音频开发感兴趣的朋友也适合想做点有意思的个人项目、又不知道从哪里下手的新手。我动手前先让自己回答三件事报时到底报给谁听、报多细、以及最可能在哪个环节失败。这三个问题的答案直接决定了后面的编码方案、音频引擎和后台任务怎么设计。1. 一个从深夜冒出来的产品冲动时间为什么要“听”1.1 被屏幕绑架太久想换一种方式感知时间现代人看时间是反射性的抬起手腕、点亮手机、瞥一眼状态栏整个过程不到一秒。这种“看时间”太顺畅了顺畅到我们已经忘记时间本身是有节奏的。老式座钟的整点敲击、火车站广播里的“现在是北京时间十二点整”本质上都是在用听觉把时间“说”出来。我迷上短波电台那阵子夜里经常戴着耳机听电报。电报信号有固定的节奏点和划之间的时长比例是死的听多了以后我甚至能从信号里分辨出对方是在发数字还是在发字母。某个深夜我突发奇想如果把手机上的数字时间也编码成这种节奏播出来会不会形成一种新的时间感知或者说会不会让“知道时间”这个行为重新变得有仪式感于是这个安卓报时应用立项了。它的核心功能很简单屏幕熄灭时到了设定时间用摩斯密码把当前的“时”和“分”以音频形式播出来。比如凌晨两点你听到的是一短四长、三短两长不用睁眼就知道现在是2:30。1.2 动手前先回答三个问题给谁听、听多细、哪里会挂第一报时对象是谁。不是电台爱好者也不是电报员而是所有想“用耳朵感知时间”的普通安卓用户。所以编码不能太难最好只涉及0到9十个数字不掺字母。普通人花一个下午就能记住数字的摩斯码。第二报多细。我决定默认只报整点因为绝大多数场景不需要分钟级听觉提醒用户如果需要可以单独开启“半点报时”或者“每分钟报时”但默认不做。这么做还有个好处整点报时一小时只响一次不会变成打扰源。第三最可能在哪个环节失败。我把失败模式列了三条后台被系统杀掉导致到点不响、音量被静音导致响了也听不见、音频时序抖动导致点和划分不清。这三条在后面的技术选型里是最高优先级音频引擎和后台调度都是围着它们设计的。这三件事看起来没什么技术含量但它们决定了整个项目的骨架。一个报时应用如果用户把开关打开后第二天没响那不管编码方案多精巧、音色多好听都等于零。2. 时间编码方案把 14:30 变成点划节奏的数学题2.1 数字的摩斯映射以及为什么只报数字摩斯密码的数字编码非常规整从0到9每个数字都是正好五个符号数字摩斯码0-----1.----2..---3...--4....-5.....6-....7--...8---..9----.这套映射最大的优点是自带了长度信息只要是数字永远是五个符号不存在“一个数字码和一个字母码撞车”的问题。所以数字之间只需要用停顿就能分隔不需要额外的结束标记。这在电报时代是刻意设计过的报时场景直接沿用就好。为什么不报“十四点三十分”这种自然语言原因很现实。自然语言转摩斯需要额外处理“十四”“点”“三十分”这些读音和分词规则复杂度上来了听者还要在脑子里多一步“中文读音→时间”的转换。电报报时的传统本来就是直接报数字序列我沿用了这个习惯。用户只需要记住十个数字的编码全局通用没有任何额外学习负担。2.2 停顿语法时与分之间的“标点符号”有了数字映射下一个问题是怎么让用户区分“前半段是小时后半段是分钟”。我的方案是引入不同长度的停顿间隔。摩斯码标准里符号与符号之间的间隔是1个单位字母与字母之间的间隔是3个单位单词与单词之间的间隔是7个单位。我直接把7个单位用作“小时与分钟之间的标点”。实际听感是这样的在一个数字内部点和划之间是很短的空隙换到下一个数字时会有一个稍长的顿挫从小时切到分钟时停顿明显比数字间更长像句子里的分号。只要听过几次用户就能清楚感知“哦前面是小时后面是分钟”。同时还有一个简化如果分钟是00就只播小时部分分钟部分直接省略。所以12:00只会响起 .---- ..--- 短促收尾12:30则会在小时播完后出现一个明显的长停顿再播分钟部分。这样一来整点和非整点的信号天然可区分不需要额外规则。这个设计我在后期测试里发现意外地好用很多用户反馈“听到短节奏就知道是整点不用数点划”。2.3 一段可以直接跑的编码函数编码逻辑不复杂我把它写成了一个独立的工具类。这样UI层可以直接用它生成预览文本音频引擎也能复用同一个解析入口。public final class TimeMorseCodec { private static final String[] DIGITS { -----, // 0 .----, // 1 ..---, // 2 ...--, // 3 ....-, // 4 ....., // 5 -...., // 6 --..., // 7 ---.., // 8 ----. // 9 }; /** 把小时和分钟编码为含空白分组的摩斯串 */ public static String encode(int hour, int minute) { if (minute 0) { return encodeDigits(hour); } return encodeDigits(hour) encodeDigits(minute); } private static String encodeDigits(int value) { String s String.format(%02d, value); // 4 - 04, 14 - 14 StringBuilder sb new StringBuilder(); for (int i 0; i s.length(); i) { if (i 0) sb.append( ); sb.append(DIGITS[s.charAt(i) - 0]); } return sb.toString(); } }注意这里有个小约定单个空格分隔同一段里的两个数字三个空格分隔小时和分钟。后续音频引擎解析字符串时就是靠空格数量决定停顿长度的。这种字符串约定方便调试我在应用里加了一个“当前时刻摩斯码预览”的显示框直接把encode的返回值打印在界面上用户就能对着屏幕对照听。3. 安卓端音频引擎先合成为整段PCM再交给AudioTrack3.1 为什么不用ToneGenerator非要用AudioTrack安卓系统自带一个叫ToneGenerator的音频API专门用来播DTMF和提示音。最早我想偷懒打算用startTone播一个短哔声再配合线程sleep控制停顿。实际跑了一次立刻放弃ToneGenerator是异步播放你调用startTone之后无法精确知道声音从哪个采样点开始两个音之间的间隔只能靠线程sleep控制而安卓的线程调度并不实时sleep几十毫秒的漂移是常事。摩斯码里“点”是1个单位时长“划”是3个单位时长二者区别完全建立在精确的时间比例上。如果每个音的开始时间漂移十几毫秒用户就会把“点”听成“划”。所以音频方案必须绕开ToneGenerator使用AudioTrack把所有要播的内容事先合成进一整段PCM数据一次性交给系统播放。播放过程中不依赖任何sleep或线程调度时间轴是锁死的。3.2 合成正弦波采样率、幅度、包络的细节我用的是44.1kHz采样率、单声道、16位PCM。44.1kHz兼容性最稳几乎所有设备原生支持不需要额外采样率转换单声道符合电报机单音源的本质不用为立体声多花内存。生成一个“音”的核心逻辑是合成一段正弦波另外在首尾各加了5毫秒的淡入淡出包络。这段代码看起来不复杂但包络那两行很关键private short[] buildTone(int durationMs, double freqHz, int sampleRate) { int total sampleRate * durationMs / 1000; short[] buf new short[total]; double fadeMs 5.0; int fadeSamples (int) (sampleRate * fadeMs / 1000); for (int i 0; i total; i) { double t (double) i / sampleRate; double env 1.0; if (i fadeSamples) env (double) i / fadeSamples; if (i total - fadeSamples) env (double) (total - i) / fadeSamples; double value Math.sin(2.0 * Math.PI * freqHz * t) * Short.MAX_VALUE * 0.5 * env; buf[i] (short) value; } return buf; }幅度我留到0.5没敢打满。原因是系统扬声器在高音量下会有非线性压缩PCM幅度接近削波时声音一拉高就发“劈”。0.5这个值留足了余量实测系统音量满格也不会明显失真。3.3 整段缓冲的组装逻辑和时序预算有了单个音的生成函数接下来的事就是把整个报时序列拼成一段连续的short数组。我的做法是维护一个ArrayList收集所有片段遍历编码字符串遇到点追加100ms音再追加100ms静音遇到划追加300ms音再追加100ms静音遇到单个空格追加200ms静音配合前面的100ms凑成300ms字符间隔遇到三个空格再追加600ms静音配合前面的100ms凑成700ms间隔private short[] buildCompletePcm(String morse, int unitMs, int sampleRate) { Listshort[] parts new ArrayList(); for (int i 0; i morse.length(); i) { char c morse.charAt(i); if (c .) { parts.add(buildTone(unitMs, 800, sampleRate)); parts.add(new short[sampleRate * unitMs / 1000]); } else if (c -) { parts.add(buildTone(unitMs * 3, 800, sampleRate)); parts.add(new short[sampleRate * unitMs / 1000]); } else if (c ) { int extra (morse.charAt(i 1) ) ? unitMs * 6 : unitMs * 2; parts.add(new short[sampleRate * extra / 1000]); } } int totalLen 0; for (short[] p : parts) totalLen p.length; short[] result new short[totalLen]; int offset 0; for (short[] p : parts) { System.arraycopy(p, 0, result, offset, p.length); offset p.length; } return result; }整个报时序列拼完后用AudioTrack一次性播放。我选的传输模式是MODE_STATIC不是MODE_STREAM。一次报时的PCM数据量不过几十万个short几兆字节而已完全能一次性装进缓冲区还避免了播放过程中频繁write造成的时序波动。用标准单位100毫秒来算一个数字的播报时长在1.1秒数字4到1.9秒数字0之间。完整报时的时间预算大致如下报时内容时长估算12:00只报小时约3.6秒14:30小时长停顿分钟约7.1秒23:59最坏情况约7.9秒这个长度不算拖沓而且100毫秒能让点、划、间隔三种长度在听感上有明确区分。我后来在设置里加了快档80毫秒和慢档130毫秒训练模式里还有160毫秒的极慢档但那都是后话默认100毫秒是最稳的起点。4. 后台报时调度整点响的前提是“服务不挂”4.1 前台服务、通知渠道与五秒限制安卓的后台限制做过定时任务的开发者应该都有体会。8.0以后屏幕熄灭一小会儿后台Service就可能被回收。我采用的方案不是“常驻后台服务”而是“平时不启动、到点临时拉起一个前台服务、播完立刻自我结束”。这样既省电又避免了被系统当成长期后台任务处理。这里有几个必须处理的细节。targetSdk 34之后startForegroundService()被调用后必须在5秒内调用startForeground()否则直接崩溃。所以我在广播接收器的onReceive里第一件事就是发通知、起前台然后再去合成PCM绝不能让耗时的初始化卡在服务启动之前。前台服务的类型声明也要注意。播放报时音频我用的是specialUse类型在Manifest里声明FOREGROUND_SERVICE_SPECIAL_USE权限。虽然上架时需要填写用途描述但使用上比申请媒体播放类型省心。通知是前台服务免不了的我把它设成低优先级只在下拉抽屉里显示一行“摩斯报时已就绪”平时完全不打扰用户。另外建议minSdk直接定到26这样通知渠道、前台服务类型这些机制都能统一处理不用为老系统写分支。4.2 AlarmManager与Doze模式的配额定时报时最合适的系统API是AlarmManager.setExactAndAllowWhileIdle()。这个方法会尽量精确地唤醒设备并且在Doze省电模式下也能触发。系统对这类闹钟有配额限制但整点报时一天24次配额完全够用。我有两点经验想分享。第一不要在AlarmManager里设置重复闹钟而是每次触发后重新计算下一次整点并再次setExact。重复闹钟在系统重启后可能被清理而一次性闹钟配合BOOT_COMPLETED广播重设可靠性反而更高。第二用户关闭开关时一定要调用cancel()取消PendingIntent否则就会出现“关了还在响”的鬼畜情形这是我踩过的坑。锁屏状态下的音频播放不需要额外申请WakeLock。AlarmManager唤醒后系统会持有一部分唤醒锁而一次报时播放不过几秒钟足够完成真正需要WakeLock的是长时间的随机练习模式那个我单独做了处理。4.3 国产ROM、电量管理、和音频焦点标准安卓之外真正的难点是设备差异。华为、小米、OPPO、vivo这些系统都有各自的“省电精灵”即使AlarmManager设得完全正确也可能被休眠策略吞掉。我在设置页放了一个“自检清单”引导用户去三个地方允许自启动、允许后台活动、忽略电池优化。这个引导对于报时类应用是刚需不做的话用户第二天就会发现开关是开的、但时间不响。音频流类型我默认用AudioAttributes.USAGE_ALARM。好处是媒体音量被静音时闹钟流依然能出声坏处是部分手机闹钟音量默认比较低用户把媒体音量调满却发现声音很小。所以我在设置里保留了“跟随媒体音量”选项让用户按自己的习惯选择。实测用USAGE_ALARM还有一个额外优势很多国产ROM对闹钟流处理得比较干净不会像媒体流那样叠加强化效果音色更接近原始PCM。音频焦点方面短促的报时音不需要长时间占用焦点。我默认不申请焦点直接播放实测听音乐时报时声可以正常混音不影响原App个别手机会出现抢焦点的问题我在设置里留了一个“播放时降低其他声音”的开关需要就打开。5. 声音可听性优化从“能响”到“听得舒服”5.1 爆音与“咬字”的软处理第一个版本在真机上跑起来时我的第一反应是“这声音不对劲”。每个音开始和结束的瞬间都带着“嗒”的一声杂音像是电台调频不准时的底噪。原因很快定位正弦波从0相位直接跳到满幅度产生了阶跃而阶跃在听感上就是爆音。加了5毫秒淡入淡出后这个问题彻底消失。后来我特意把淡入淡出从2毫秒一路调到10毫秒对比最终确定5毫秒是最合适的不影响100毫秒“点”的饱满度又足够让咬字柔和。调这个参数需要耐心我在不同设备上试了快一周结论是5毫秒在绝大多数手机扬声器上都没有可感知的损耗。音量方面我坚持把正弦波幅度控制在0.5。系统音量越高扬声器对高频峰值的压缩越明显PCM幅度接近削波就会发劈。0.5幅度下即使系统音量拉满也不会失真这个余量是必要的。5.2 音色从干净正弦波到“电报味的加成”纯正弦波在手机扬声器上听起来有点像电子闹钟不算难听但总缺一点电报机的人味。我花了两三天调音最后在正弦波上叠了一层白噪声幅度只有主音的8%同时给每个音的尾音加了一点指数衰减int sampleRate 44100; double noise (random.nextDouble() * 2 - 1) * Short.MAX_VALUE * 0.08 * env; double tone Math.sin(2.0 * Math.PI * freqHz * t) * Short.MAX_VALUE * 0.5 * env; buf[i] (short) Math.max(Short.MIN_VALUE, Math.min(Short.MAX_VALUE, tone noise));这样做出来的声音不再是干净的“哔———”而带着一点旧式振铃器“哒——嗯”的余韵听感上更有年代感。频率最终固定在800Hz。我试过600Hz更接近真实电报机但手机扬声器低频响应普遍不足听起来闷也试过1000Hz穿透力强了但长时间听刺耳。800Hz在绝大多数手机扬声器上都能兼顾清晰与柔和这也是我做其他音频小工具时推荐的提示音起始频率。5.3 语速档位和训练模式听久了真的能“听懂”开发到后期我越来越确定一件事这款应用对普通用户最大的价值不只是报时而是提供了一个低门槛的摩斯码听力练习场。因为报时会不断重复数字编码一天听几次十天半个月下来用户会发现“....-”是4、“---..”是8这种条件反射不需要刻意背。为了把练习曲线做好我加了四档语速快档80毫秒接近真实电报速度适合有一定基础的爱好者标准100毫秒默认咬字清晰慢档130毫秒适合刚上手时分辨点划训练档160毫秒基本不会听错适合零基础用户训练模式里点一下“随机报时”应用随机生成一个时间播出来然后屏幕上显示四个数字的摩斯码作为参考答案。用户先闭眼听再对照验证。这套交互很简单却比我预想的更受欢迎它把“被动接收报时”变成了“主动听力练习”爱好者反馈说比背码表高效得多。我个人做下来的体会是一个报时小应用做到这个程度核心价值已经完整了编码简单、音频精确、后台稳定、声音舒服再加上一个有可玩性的练习模式就足以覆盖摩斯密码爱好者、通勤路上想练听力的人、以及所有对“用耳朵感知时间”这件小事感兴趣的用户。接下来如果还有精力我会考虑把日期也纳入编码让长期使用者连“今天是几号”都能用听力判断。对于一款小众工具应用能在真实使用中不断带来“我竟然真的听懂了”的成就感比任何功能堆砌都更有意义。
返回列表