用本机跑的开源音乐模型,给安卓小游戏做 20 秒无缝循环背景音乐

给一个安卓小游戏配背景音乐,我定了三条要求:

  1. 循环播放听不出接缝;
  2. 游戏里有三种气氛(平常的周、相亲当天、数值告急),三段曲子要能来回切,切的时候不跳拍;
  3. 体积要小,装进 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 拍/分,小节线和和弦走向也各不相同,叠在一起播会别扭。我的做法是:

  1. 主曲用 text2music 生成;
  2. 另外两段用 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 封闭测试,测试的人还不够。想试的话,先加测试群,再从测试页安装:

为人带来一点快乐。永远免费,永远没有广告。

联系:techinfor404@gmail.com

官网:https://techinfo-apps.github.io/

相关推荐
小lo想吃棒棒糖1 小时前
把 启元机器人Q1 的训练搬到昇腾 910B:从 MuJoCo 到 PPO 的一次完整迁移
人工智能·python·机器人·昇腾·具身智能
企业数字化笔记1 小时前
表单发布前怎么验收?版本引用、字段兼容和历史数据回归
android·数据挖掘·回归
weixin_307779131 小时前
RAG 级联失效分析——面试中的错误归因艺术
人工智能·python·面试·职场和发展
AI创界者1 小时前
Python 进阶实战(十八):API 网关安全与异步鉴权中间件 —— 基于 AsyncIO 的 JWT 零拷贝验权与动态黑名单过滤
人工智能·python·aigc
生信分析笔记1 小时前
Nature 正刊 GPN-Star 基因组语言模型
android
渣渣盟1 小时前
Flink 状态管理深度剖析:从 KeyedState 到 RocksDB,状态如何存储、恢复与扩缩容
android·大数据·flink
avi91111 小时前
电影开源Jevdo - 源码介绍(vue版)没有android 和 没有wpf
android·wpf·框架·数据爬虫·meego·电影管理
前端精髓1 小时前
学会调用Qwen3.7-通用文本向量
python
OxYGC1 小时前
[工程] PHP2Shell 漏洞解析:两条 PHP 内核 RCE,为什么应用层的防护全部失效
android·开发语言·php