给一个安卓小游戏配背景音乐,我定了三条要求:
- 循环播放听不出接缝;
- 游戏里有三种气氛(平常的周、相亲当天、数值告急),三段曲子要能来回切,切的时候不跳拍;
- 体积要小,装进 App 里不心疼。
曲子我没有自己谱,而是用一个在本机跑的开源音乐模型(ACE-Step 1.5)生成,再用一个 Python 脚本把生成的曲子切成正好 20 秒的无缝循环,安卓端用 SoundPool 播放。这篇把整条流程和踩到的坑写下来,游戏只是个例子,换成你的项目照样能用。

一、先弄清"接缝"从哪来
一段音乐循环播放,接缝可能出在三个地方:
- 节奏接缝:长度不是整数拍。每转一圈,重拍的位置就偏一点,转几圈后明显不对劲。
- 波形接缝:结尾最后一个样本和开头第一个样本数值差得大,播放时是一声"咔哒"。
- 和声音色接缝:结尾停在一个和弦上,开头却是另一种音色另一个和弦,听感上断了一下。
所以目标是:长度正好是整数小节,并且"起点"和"终点"听起来像同一个地方。
我选的规格:96 拍/分钟,4/4 拍,8 小节 = 32 拍。一拍 60 ÷ 96 = 0.625 秒,32 拍正好 20.0 秒 ;采样率 22050 Hz,所以是 441000 个样本,单声道 16 位。一条 wav 是 882044 字节(882000 字节数据加 44 字节文件头),三条加起来 2.6 MB 出头。SoundPool 本来就是给短音频用的,20 秒的循环正适合它。

二、生成:为什么要多生成一倍
我让模型生成 40 秒,而不是直接生成 20 秒。原因有两个:
- 生成出来的曲子开头常有"起"的部分,结尾常有收尾,这些不适合循环,我只想要中间稳定的那一段;
- 之后要在里面"找一个起点",得有挑的余地。
生成参数大概是这样(ACE-Step 1.5 的 turbo 版加 0.6B 的语言模型,8 步):
python
p = GenerationParams(
lyrics="[Instrumental]", instrumental=True, # 纯器乐
bpm=96, timesignature="4", keyscale="C Major",
duration=40, inference_steps=8, guidance_scale=1.0,
task_type="text2music", thinking=True,
caption=MAIN, seed=11)
caption 就是一句英文的风格描述,我写的是"轻快的 8 位像素风纯器乐循环、方波主旋律、三角波贝斯、轻噪声打击乐、速度平稳、没有人声"这个意思。在一台 M2 Max 的 Mac 上,一条 40 秒的曲子大约 30 秒就出来了。
要注意:模型收到 bpm=96 只是"尽量往这个速度靠",生成结果的实际速度不会正好是 96。我拿 librosa 检测过,三条分别是 95.3、97.0、95.3 拍/分。所以后面一定要有"微调速度"这一步。
三、切循环:四步
1. 找拍点
python
tempo, beats = librosa.beat.beat_track(y=y, sr=sr, start_bpm=96,
units="time", tightness=400)
start_bpm 告诉它大概的速度,tightness 调大,让拍点更贴着稳定的节奏、不要随音符乱跳。
2. 挑起点:让"起点"和"32 拍之后"最像
第 i 个拍点作为起点,那循环的终点就是第 i+32 个拍点。我要挑的是这样一个 i:起点那一刻和终点那一刻,和声和音色最接近(这样首尾接起来,耳朵不会觉得换了地方)。
怎么量化"最接近"?我给每一小段做一个"指纹":12 维的 chroma(音高类别的能量分布,反映和声)加 13 维的 MFCC(反映音色),把 MFCC 除以 50,让两部分的数量级差不多。取拍点后 4 帧的平均,算两个指纹的欧氏距离,取负数当"接缝得分",越接近 0 越顺:
python
def feats(y, sr):
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
return np.vstack([chroma, mfcc / 50])
def seam_at(feat, fr, i):
a = feat[:, fr[i]:fr[i] + 4].mean(1)
b = feat[:, fr[i + 32]:fr[i + 32] + 4].mean(1)
return -float(np.linalg.norm(a - b))
然后把所有可行的 i 过一遍,取得分最高的。可行的意思是:起点离开头至少 3 秒(避开"起"的部分),终点后面还要留 0.5 秒(后面交叉淡化要用)。
为什么不直接比波形?因为同一首曲子的两个不同位置,波形几乎不可能一样,比出来没有意义;要比的是"听起来像不像"。

3. 微调速度,让这段正好 20.0 秒
拍点检测出来的 32 拍长度不一定是 20.0 秒(比如 20.3 秒),拉伸一下就行:
python
rate = (t1 - t0) / 20.0 # t0、t1 是起点和终点的时间
if abs(rate - 1) > 0.001:
seg = librosa.effects.time_stretch(seg, rate=rate)
速度越准,要拉伸的就越少,音质损失就越小。我生成了 3 个候选(3 个随机种子),其中一个候选的 32 拍原长分别是 20.0、19.98、20.0 秒,几乎不用拉伸;另一个要拉伸大约 2%。这也是我选候选的依据之一。
4. 交叉淡化,把最后一道接缝抹平
即使起点挑得再好,首尾样本也不会正好相等。办法是:让循环段的开头,先是"原曲里紧跟在终点之后的那 60 毫秒",再渐渐过渡到真正的开头。这样循环时,终点的下一个样本,正是原曲里本来就该接上去的那个样本:
python
n, xf = 20 * 22050, int(0.06 * 22050) # 441000 个样本,60 毫秒
body = seg[s:s + n].copy()
tail = seg[s + n:s + n + xf] # 终点之后的 60 毫秒
k = np.linspace(0, 1, xf)
body[:xf] = body[:xf] * np.sqrt(k) + tail * np.sqrt(1 - k)
用 sqrt 而不是直线,是"等功率"交叉淡化:两路信号叠加的时候总能量保持不变,中间不会凹下去一块。
做完之后顺手把响度对齐到一条参考曲子(按 RMS),再检查峰值不超过 0.98,避免削波。
怎么确认接缝真的没有"咔哒"?我算了一个小指标:首尾两个样本的差,除以整段里相邻样本差的 99 分位。小于 1,就说明接缝处的跳变没比曲子里平时的跳变更大。选中的那一组,三条的指标分别是 0.58、0.08、0.66。
四、一个坑:变速和重采样会把片段两头"糊掉"
最开始我的做法很直接:按拍点把 [t0, t1] 这一段剪出来,再拉伸、再重采样。结果接缝处总有一点咔哒声,怎么调淡化长度都不干净。
后来发现是变速(time_stretch)和重采样,在片段的两头处理得都不准------这类算法是按一小块一小块来处理的,片段最开头和最末尾的块缺了一边的上下文,出来的头尾就是糊的。偏偏我要的接缝恰恰就在头尾。
解决办法:前后各多带 1 秒,先对这一大段做变速和重采样,处理完再从中间取需要的 20 秒:
python
pad = 1.0
a, b = max(0, int((t0 - pad) * sr)), int((t1 + pad) * sr)
lead = t0 - a / sr # 起点在这段里的位置
seg = y[a:b]
seg = librosa.effects.time_stretch(seg, rate=rate)
seg = librosa.resample(seg, orig_sr=sr, target_sr=22050)
s = int(round(lead / rate * 22050)) # 变速后,起点的位置也跟着缩放
body = seg[s:s + n] # 从"没糊"的中间取
两个细节:变速之后时间轴整个缩放了,所以起点的位置要除以 rate;多带的这 1 秒,也正好给上一步的 60 毫秒交叉淡化提供了"终点之后的素材"。
五、三条曲子怎么做到同拍子
游戏里有三种气氛,对应三段曲子。我想要的切换方式是:三段同时播,只让其中一段出声,切换就是调音量。这要求三段曲子的每一拍都对齐。
如果三段各自独立生成,即使都写了 96 拍/分,小节线和和弦走向也各不相同,叠在一起播会别扭。我的做法是:
- 主曲用
text2music生成; - 另外两段用
task_type="cover",把主曲当src_audio,用同一个随机种子,audio_cover_strength=0.6,换一句风格描述(一个是"正午小饭馆、礼貌又有点尴尬"的轻快曲,一个是"钟表滴答加心跳底鼓、小调"的紧张曲),让模型按主曲的骨架改编。
python
gen("music_date-1", task_type="cover", thinking=False, caption=DATE,
src_audio=main_wav, audio_cover_strength=0.6, seed=seed)
切循环的时候,同一组里的另外两段沿用主曲的起点:在它们自己的拍点里找离主曲起点最近的那一个,往后取 32 拍。
但 cover 并不保证拍点完全对齐,所以我把"三条起点的互差"也当成指标记下来。三个候选对比如下(接缝得分越接近 0 越好):
| 候选 | 变速 | 三条接缝得分 | 三条起点互差 | 结论 |
|---|---|---|---|---|
| 1(选中) | 几乎不用 | −1.30 / −1.18 / −0.70 | ≤ 0.011 秒 | 速度最准,拍点对齐 |
| 2 | 约 2% | −1.24 / −3.04 / −5.98 | 0 | 后两条接缝差,变速最多 |
| 3 | 约 1% | −0.38 / −0.57 / −0.50 | 其中一条差 0.30 秒 | 接缝最好,但那一条和主曲错了约半拍(一拍 0.625 秒) |
候选 3 的接缝最漂亮,却不能用:错半拍的两条同时播,会很别扭。这说明接缝得分只是一项指标,同步性是另一项,要一起看;一组曲子是同源的,不合格就整组换,不能只换其中一条。
这些指标只能帮我把明显有问题的筛掉,最后拍板之前还是得戴上耳机自己听一遍。
六、安卓端:SoundPool 三条并排循环
三条 wav 放在 res/raw/,用 SoundPool 加载。要点有三个:
- 三条都加载完,再一起开始播;
play的loop参数传-1,无限循环;- 同一时刻只有一条有音量,其余音量为 0,切换时每 80 毫秒把音量旋钮转一小格(0.08),大约 1 秒完成淡入淡出。
kotlin
private val pool = SoundPool.Builder()
.setMaxStreams(6)
.setAudioAttributes(AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_GAME)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
.build()
// 三条一起开始,只有"平常"那条有音量
calmStream = pool.play(calmSound, MUSIC_VOLUME, MUSIC_VOLUME, 2, -1, 1f)
dateStream = pool.play(dateSound, 0f, 0f, 2, -1, 1f)
tenseStream = pool.play(tenseSound, 0f, 0f, 2, -1, 1f)
// 切换时只调音量,不换曲子,所以拍子不会断
pool.setVolume(tenseStream, MUSIC_VOLUME * blend, MUSIC_VOLUME * blend)
这样换曲子时只是音量的变化,三条曲子始终在各自的循环里走着同一个拍子,所以不会跳拍。
三次 play 是连着调用的,三条的起点理论上会差几毫秒,我没有单独量化这个误差。
另外几个小处理:
- 音乐默认是关的,玩家自己打开;开着的时候,如果手机里已经有别的音乐在放(
AudioManager.isMusicActive),游戏启动时不去抢; - 游戏切到后台就
autoPause,回来再autoResume; - 音效和音乐各有一个开关,各自记住。
七、许可
ACE-Step 1.5 的模型卡和代码仓库写的是 MIT 许可,用的是授权数据训练、生成的音乐可商用。用之前请自己再确认一遍模型卡,这篇文章不替你担保。
八、顺便说说这个游戏
例子里的游戏是我们做的一个小游戏,《相亲模拟器》:像素风的卡牌小游戏,左右滑卡片,应付一个相亲季里家人的催促、介绍人的热情和自己的钱包、心情。笑的是拿标签挑人这套做法,不笑任何一个人。
0.2 版有 62 张卡,6 位相亲对象(一个相亲季见其中三位),还有上面说的三段背景音乐和七个音效。不联网,不要任何权限,七种语言,永远免费,永远没有广告。
它还在 Google Play 封闭测试,测试的人还不够。想试的话,先加测试群,再从测试页安装:
- 测试群:https://groups.google.com/g/blind-date-simulator-testers
- 测试页(点"成为测试人员",再到 Play 安装):https://play.google.com/apps/testing/app.blinddate.sim
- 官网:https://techinfo-apps.github.io/


为人带来一点快乐。永远免费,永远没有广告。
联系:techinfor404@gmail.com