
玩贝斯的人最怕两件事一是鼓手抢拍二是键盘手左手弹得太满把低音频段全给糊死。在传统的乐队编配里贝斯声部从来不是主旋律的附庸也不是简单的根音Root Note打桩机而是一条拥有独立呼吸和起伏的对位线条。最近在做 Web 端 AI 编曲原型时我们尝试用大模型根据输入的主旋律生成第二声部低音伴奏。如果只把主旋律的 MIDI 序列扔给自回归模型即便 Prompt 里反复强调“生成好听的 Walking Bass”出来的结果通常只有两种极端要么是毫无灵性的持续八分根音要么是低音旋律与高音主旋律频繁撞车——出现大二度打架、刺耳的平行五度或者在同一个节拍上争抢主导权。音乐理论里十六到十八世纪的复音对位法Counterpoint特别是约翰·约瑟夫·富克斯的《对位法阶梯》早就给出了声部交错的数学解法。将对位法则转化为前端可执行的约束校验层在模型生成 token 流之后进行实时的谐和度纠偏与节奏空隙填补是目前让 AI 低音线真正具备“乐队感”最稳妥的方案。为什么纯生成模型搞不好复音对位复音音乐的核心矛盾是横向线条的独立性与纵向和声的协和性之间的动态平衡。当前基于 Transformer 的符号音乐模型在处理单轨自回归预测时表现出色但在处理双轨乃至多轨时注意力机制往往被上下文中的强局部特征吸引。低音的声学物理特性决定了它与高音旋律有着完全不同的力学规律频段能量与听觉掩蔽低频40Hz~250Hz的波长极长两个低频音符若相隔小二度或增四度会在声学空间中产生剧烈的调制拍频Beating听感不是色彩丰富而是浑浊与脏乱。平行五度与平行八度禁忌古典严谨对位法严禁声部间出现平行纯八度或平行纯五度。物理本质在于完全协和音程的泛音列高度重合一旦两个声部同向且以相同纯音程进行人耳的听觉系统会瞬间把两个声部聚类合并为一个声部低声部的独立线条感直接蒸发。反向进行Contrary Motion的缺失优秀的低音线条在主旋律向上跳进时往往选择反向向下或保持斜向移动形成张力的张开与收缩。大模型如果没有显式约束极易随波逐流地采用“同向进行Similar Motion”听起来平庸且黏糊。因此单纯指望 LLM 悟出严格对位是极其低效的。我们在前端工程中的策略是大模型负责提供带有 Groove律动和风格特征的骨架候选前端规则引擎则充当严苛的“乐队总监”对生成的低音音符序列执行实时的对位法则过滤与微调修剪。复音对位规则引擎设计对位法则在代码层可以抽象为针对“旋律对Dyad”的时序几何计算。主旋律音符为 $M(t)$生成的低音声部音符为 $B(t)$我们需要在离散时间网格上评估以下三大约束纵向协和度约束强拍Downbeat上的音程必须是协和音程三度、六度、纯五度、八度纯四度在低音声部视为不协和音程需谨慎解决若出现二度或七度等不协和音程必须在弱拍通过级进Stepwise Motion解决。横向运动学约束计算连续两个节拍的运动向量 $\Delta M M(t) - M(t-1)$ 和 $\Delta B B(t) - B(t-1)$。如果 $\text{sign}(\Delta M) \text{sign}(\Delta B)$判定为同向若此时两声部间的音程由非纯五/八度演变为了纯五/八度直接触发平行纯音程熔断。声部避让与节奏互补当主旋律处于密集切分音时低音线拉长音符作为垫底当主旋律进入长音保持时低音线通过经过音Passing Tone和辅助音Neighbor Tone激活空间。export interface NoteEvent { pitch: number; // MIDI pitch 0-127 startTick: number; // 绝对时间轴 Tick duration: number; // 持续时间 Tick velocity: number; // 力度 1-127 } export interface MotionPair { prevInterval: number; currentInterval: number; isParallelFifthOrOctave: boolean; isContraryMotion: boolean; } export class CounterpointEngine { // 协和半音距离集合3(小三), 4(大三), 7(纯五), 8(小六), 9(大六), 12(八度) private readonly CONSONANT_INTERVALS new Set([3, 4, 7, 8, 9, 12, 15, 16, 19]); /** * 计算两个 MIDI 音高的半音程差模 12 (折算在八度内或保留绝对八度) */ public getInterval(highPitch: number, lowPitch: number): number { return Math.abs(highPitch - lowPitch); } /** * 判定两个连续时钟点的声部运动特性 */ public analyzeMotion( mPrev: NoteEvent, mCurr: NoteEvent, bPrev: NoteEvent, bCurr: NoteEvent ): MotionPair { const prevDelta mCurr.pitch - mPrev.pitch; const currDelta bCurr.pitch - bPrev.pitch; const prevInterval Math.abs(mPrev.pitch - bPrev.pitch) % 12; const currentInterval Math.abs(mCurr.pitch - bCurr.pitch) % 12; // 是否同向移动 const isParallelDirection (prevDelta 0 currDelta 0) || (prevDelta 0 currDelta 0); // 反向进行 const isContraryMotion (prevDelta 0 currDelta 0) || (prevDelta 0 currDelta 0); // 平行五度 (7) 或 平行八度 (0 / 12) const isParallelFifthOrOctave isParallelDirection prevInterval currentInterval (currentInterval 7 || currentInterval 0); return { prevInterval, currentInterval, isParallelFifthOrOctave, isContraryMotion, }; } /** * 核心纠偏管线对大模型生成的低音序列进行动态约束与变调修剪 */ public rectifyBassline( melodyTrack: NoteEvent[], generatedBassTrack: NoteEvent[] ): NoteEvent[] { const rectifiedBass: NoteEvent[] []; for (let i 0; i generatedBassTrack.length; i) { const bCurr { ...generatedBassTrack[i] }; // 寻找与当前低音音符时间重叠的主旋律音符 const mCurr melodyTrack.find( (m) bCurr.startTick m.startTick bCurr.startTick m.startTick m.duration ); if (!mCurr) { // 主旋律处于空隙低音拥有绝对自由度保留原音 rectifiedBass.push(bCurr); continue; } let interval this.getInterval(mCurr.pitch, bCurr.pitch) % 12; // 如果有上一个低音与主旋律对检测平行五/八度 if (i 0 rectifiedBass.length 0) { const bPrev rectifiedBass[rectifiedBass.length - 1]; const mPrev melodyTrack.find( (m) bPrev.startTick m.startTick bPrev.startTick m.startTick m.duration ); if (mPrev) { const motion this.analyzeMotion(mPrev, mCurr, bPrev, bCurr); if (motion.isParallelFifthOrOctave) { // 违背对位禁忌强行推向反向进行的三度或六度 const fallbackPitch mCurr.pitch mPrev.pitch ? bCurr.pitch - 2 : bCurr.pitch 2; bCurr.pitch fallbackPitch; interval this.getInterval(mCurr.pitch, bCurr.pitch) % 12; } } } // 强拍不协和音修剪如果落在强拍且处于二度、七度、三全音强制吸附至最近的协和音 const isStrongBeat bCurr.startTick % 480 0; // 假设 480 PPQ 为四分音符强拍 if (isStrongBeat !this.CONSONANT_INTERVALS.has(interval)) { bCurr.pitch this.resolveDissonance(mCurr.pitch, bCurr.pitch); } rectifiedBass.push(bCurr); } return rectifiedBass; } /** * 将不协和低音音高平移至最近的协和音程优先三度、六度 */ private resolveDissonance(melodyPitch: number, bassPitch: number): number { const candidates [bassPitch - 1, bassPitch 1, bassPitch - 2, bassPitch 2]; for (const candidate of candidates) { const interval Math.abs(melodyPitch - candidate) % 12; if (interval 3 || interval 4 || interval 8 || interval 9) { return candidate; } } return bassPitch - 1; // 保底微调 } }大模型 Prompt 的结构化输出锚定光有后处理规则是不够的。如果大模型输出的低音序列完全脱离了调式调性后处理引擎就会频繁进行大距离的暴力音高修正破坏原有的贝斯旋律走向。必须在 Prompt 中给自回归模型划定清晰的低音声部行为规范音区硬约束Register Ceiling Floor低音声部严格限制在 MIDI 音高 28E1到 55G3之间避免侵入中音吉他或钢琴声部。步进偏好Motion Distribution在 Few-Shot 样本中显式提供级进为主、四五度跳进解决的低音走向禁止无逻辑的连续八度大跳。输出格式紧凑化使用 JSON 压缩格式直接返回[tick, pitch, duration, velocity]元组省掉自然语言标记降低 token 消耗并提升前端流式反序列化的吞吐速率。export function buildCounterpointPrompt( melodyMidi: { tick: number; pitch: number; dur: number }[], scale: string, bpm: number ): string { return Role: You are a professional bassist and classical counterpoint composer. Task: Generate a counterpoint bassline (MIDI 28-55) for the given melody. Musical Rules: 1. Strict contrary motion when melody moves by leap ( 4 semitones). 2. Avoid parallel 5ths and octaves completely. 3. Target consonant intervals (3rds, 6ths, 10ths) on strong metric beats (tick % 480 0). 4. Use passing tones (2nds) strictly on weak subdivisions. Scale: ${scale} | Tempo: ${BPM} | PPQ: 480 Input Melody: ${JSON.stringify(melodyMidi)} Output ONLY a JSON array of bass notes format: [[tick, pitch, duration, velocity]]: .trim(); }真实运行中的声学权衡在浏览器端把这套对位管线接到底层合成器如 Web Audio 的 OscillatorNode 加波形塑形器时有几个实际落地的物理细节必须考虑第一低音释放时间的声学膨胀。由于贝斯音色包含丰富的低频下潜Sub-Bass如果两个音符之间没有保留 15~30ms 的空隙Rest在立体声扬声器中听起来就会像一条持续不断的闷响。因此在CounterpointEngine之后需要对低音时长进行duration duration * 0.9的微衰减给发音体留出阻尼消音的物理时间。第二反向进行不等于机械反向。如果主旋律大跳向上低音一味大跳向下会导致音区瞬间拉开到三个八度以上声部间出现声学脱节Acoustic Disconnection。对位法的智慧在于“以小步反向对抗大步同向”主旋律向上跳进五度时低音向下微移半音或全音即能构建出极其坚固的复音支撑。将音乐理论沉淀为确定性的算法过滤器配合大模型的概率生成既避免了纯算法的死板机械又杜绝了生成式 AI 不受控的声学翻车。这就是把贝斯低音线交织进现代 Web 音频应用的正确姿势。