AI 音乐协作平台:多用户实时编辑与版本管理的架构设计

AI 音乐协作平台:多用户实时编辑与版本管理的架构设计

一、两个人同时在调同一段和弦,最后一保存就覆盖了对方的工作

AI 音乐协作平台需要解决的核心问题不是 AI 生成——是多人协作。一个人用 AI 调好了一段旋律,传给第二个人加和声,第二个人改了和弦进行再传回来——第一段旋律已经完全变了。这是典型的"覆盖式协作"问题,Google Docs 在 2010 年就用 OT(操作转换)算法解决了,但音乐协作有自己的特殊性:音频的处理单位不是字符,是音符、小节、音轨、混音参数。

音乐协作平台的架构需要支持三个层次的操作:音符级(单个音符的增删改)、段落级(小节复制、循环编排)、混音级(音量、声像、效果器参数)。这三个层次的操作粒度完全不同,冲突检测和合并策略也不同——音符碰撞可以用 MIDI 音高来解决(同样音高的音符不能重叠),混音参数有优先级(最后操作者优先)。

二、底层机制与原理剖析

音乐协作平台的三个核心技术组件:

操作模型(Operation Model):定义每种编辑操作的数据结构。例如:添加音符 →{type: "add_note", track: 1, pitch: 60, start: 4.0, duration: 1.0, velocity: 80}、删除音符 →{type: "delete_note", note_id: "n_123"}。操作模型必须是可逆的(可以 undo),这意味着每次操作都要保存"反操作"(undo log)。

冲突检测与解决:音符编辑的冲突是空间性的——两个用户往同一个位置放了不同的音符,或修改了同一个音符的音高。检测规则:同一音轨 + 时间重叠 + 音高相同 → 冲突。解决策略:优先后来者(Last-Write-Wins)或提供可视化的冲突提示让用户手动选择。

版本分支:借鉴 Git 的分支模型——主分支(main)是"正式的歌曲版本",用户可以创建 exploratory 分支("试试把副歌变成小调")。如果实验分支效果好,合并回 main;不好就删除。这在音乐制作中极其有用——"我再做一个 alternative mix"本质就是开一个新的分支。

三、生产级代码实现

// collaboration-engine.ts /** * 音乐协作引擎——基于 CRDT 的实时协作 * * 为什么用 CRDT 而非 OT? * - CRDT 不依赖中央服务器做操作转换(P2P 友好) * - CRDT 的合并是幂等的——同样的操作执行两次不影响结果 * - 音乐协作中大部分操作(添加音符、调参数)天然适合 CRDT * * CRDT 结构:G-Counter (增删计数) + LWW-Register (参数覆盖) */ // --------------------------------------------------------------------------- // 操作类型定义 // --------------------------------------------------------------------------- type OperationType = | 'note_add' | 'note_delete' | 'note_move' | 'param_change' | 'track_create' | 'track_delete' | 'section_copy'; interface MusicOperation { id: string; // 操作唯一 ID (UUID) userId: string; // 执行操作的用户 timestamp: number; // 客户端时间戳(用于 LWW 冲突解决) type: OperationType; target: string; // 目标 track/section ID // 根据 type 不同携带不同数据 data: { // note_add / note_move noteId?: string; pitch?: number; // MIDI 0-127 startBeat?: number; // 开始节拍 duration?: number; // 持续节拍 velocity?: number; // 力度 0-127 // param_change paramName?: string; // volume / pan / reverb paramValue?: number | string; // track_create trackName?: string; trackType?: 'midi' | 'audio' | 'ai'; // section_copy sourceStart?: number; sourceEnd?: number; targetStart?: number; }; } // --------------------------------------------------------------------------- // 项目文档——CRDT 数据结构 // --------------------------------------------------------------------------- interface NoteEntry { noteId: string; pitch: number; startBeat: number; duration: number; velocity: number; creatorId: string; lastModifierId: string; timestamp: number; deleted: boolean; // tombstone (墓碑)——保留已删除音符的元数据 } interface TrackState { trackId: string; name: string; type: 'midi' | 'audio' | 'ai'; notes: Map<string, NoteEntry>; // 混音参数(LWW-Register) params: Map<string, { value: number | string; timestamp: number }>; } interface ProjectDocument { projectId: string; version: number; tracks: Map<string, TrackState>; operationLog: MusicOperation[]; // 操作日志(保留最近 1000 条) snapshot: string | null; // 完整快照的引用(定期生成) } // --------------------------------------------------------------------------- // CRDT 合并引擎 // --------------------------------------------------------------------------- class MusicCRDTEngine { private doc: ProjectDocument; constructor(projectId: string) { this.doc = { projectId, version: 0, tracks: new Map(), operationLog: [], snapshot: null, }; } /** * 应用本地操作(无需网络确认,本地直接生效) */ applyLocal(op: MusicOperation): boolean { switch (op.type) { case 'note_add': return this._addNote(op); case 'note_delete': return this._deleteNote(op); case 'note_move': return this._moveNote(op); case 'param_change': return this._changeParam(op); case 'track_create': return this._createTrack(op); default: console.warn(`Unknown operation type: ${op.type}`); return false; } } /** * 合并远程操作(来自其他用户) * * 关键:LWW (Last-Write-Wins) 冲突解决 * 如果本地和远程同时修改同一参数,时间戳较新的获胜 */ mergeRemote(op: MusicOperation): boolean { switch (op.type) { case 'note_add': return this._mergeNoteAdd(op); case 'note_delete': return this._mergeNoteDelete(op); case 'param_change': return this._mergeParamChange(op); default: // 其余操作类型直接应用 return this.applyLocal(op); } } /** * 生成快照(用于新用户加入时同步完整状态) */ generateSnapshot(): string { const snapshot = JSON.stringify({ version: this.doc.version, tracks: Array.from(this.doc.tracks.entries()).map(([id, track]) => ({ id, name: track.name, type: track.type, notes: Array.from(track.notes.values()) .filter(n => !n.deleted) .map(n => ({ noteId: n.noteId, pitch: n.pitch, start: n.startBeat, duration: n.duration, velocity: n.velocity, })), params: Object.fromEntries(track.params), })), }); this.doc.snapshot = snapshot; return snapshot; } // --- 内部方法 --- private _addNote(op: MusicOperation): boolean { const track = this._ensureTrack(op.target); if (!track) return false; const noteId = op.data.noteId || this._generateNoteId(); track.notes.set(noteId, { noteId, pitch: op.data.pitch!, startBeat: op.data.startBeat!, duration: op.data.duration!, velocity: op.data.velocity || 100, creatorId: op.userId, lastModifierId: op.userId, timestamp: op.timestamp, deleted: false, }); this.doc.version++; return true; } private _deleteNote(op: MusicOperation): boolean { const track = this.doc.tracks.get(op.target); if (!track) return false; const note = track.notes.get(op.data.noteId!); if (!note) return false; // Tombstone——不做真正的删除,标记 deleted note.deleted = true; note.lastModifierId = op.userId; note.timestamp = op.timestamp; this.doc.version++; return true; } private _moveNote(op: MusicOperation): boolean { const track = this.doc.tracks.get(op.target); if (!track) return false; const note = track.notes.get(op.data.noteId!); if (!note || note.deleted) return false; note.startBeat = op.data.startBeat!; note.duration = op.data.duration ?? note.duration; note.lastModifierId = op.userId; note.timestamp = op.timestamp; this.doc.version++; return true; } private _changeParam(op: MusicOperation): boolean { const track = this._ensureTrack(op.target); if (!track) return false; const paramName = op.data.paramName!; // LWW:只有当远程时间戳 > 本地时才覆盖 const existing = track.params.get(paramName); if (existing && existing.timestamp >= op.timestamp) { // 本地版本更新——远程操作被忽略 console.debug( `Param ${paramName}: local(${existing.timestamp}) >= remote(${op.timestamp}), ignoring` ); return false; } track.params.set(paramName, { value: op.data.paramValue!, timestamp: op.timestamp, }); return true; } private _mergeNoteAdd(op: MusicOperation): boolean { // 检查是否有冲突——同一位置已有音符 const track = this.doc.tracks.get(op.target); if (track) { for (const note of track.notes.values()) { if (note.deleted) continue; // 冲突检测:时间重叠 + 音高相同 const noteEnd = note.startBeat + note.duration; const opEnd = op.data.startBeat! + op.data.duration!; if ( note.pitch === op.data.pitch && op.data.startBeat! < noteEnd && opEnd > note.startBeat ) { // 冲突!时间戳较新的保留 if (op.timestamp > note.timestamp) { note.deleted = true; // 移除旧音符 } else { return false; // 新操作被忽略 } } } } return this._addNote(op); } private _mergeNoteDelete(op: MusicOperation): boolean { // 删除操作的合并:标记 tombstone return this._deleteNote(op); } private _mergeParamChange(op: MusicOperation): boolean { return this._changeParam(op); } private _createTrack(op: MusicOperation): boolean { if (this.doc.tracks.has(op.target)) return false; this.doc.tracks.set(op.target, { trackId: op.target, name: op.data.trackName || `Track ${this.doc.tracks.size + 1}`, type: op.data.trackType || 'midi', notes: new Map(), params: new Map(), }); this.doc.version++; return true; } private _ensureTrack(trackId: string): TrackState | null { let track = this.doc.tracks.get(trackId); if (!track) { // 自动创建 track(如果不存在) track = { trackId, name: trackId, type: 'midi', notes: new Map(), params: new Map(), }; this.doc.tracks.set(trackId, track); } return track; } private _generateNoteId(): string { return `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; } }

四、边界分析与架构权衡

CRDT vs OT 的选择

  • CRDT 不需要中央协调——适合离线编辑和 P2P 协作。但 CRDT 的合并结果可能不符合"用户直觉"(如参数合并可能看到中间态)
  • OT 需要保证操作按顺序到达——适合有中央服务器的场景。合并结果更符合直觉,但实现复杂度高
  • 音乐协作推荐 CRDT——音乐操作(加音符、调参数)天然是可交换的,CRDT 的自动合并不会产生"不可能的音乐"

版本管理的深水区

  • 分支合并的冲突——如果分支 A 改了旋律且分支 B 也改了同一个 melody track,合并时需要用户手动选择
  • 建议:只允许在完整 track 级别做分支("开一个 alternative lead guitar track"),不要在一个 track 内部做分支
  • AI 辅助合并——AI 可以作为"建议者"来调和两个冲突版本("把 A 的旋律和 B 的和声组合")

性能注意事项

  • 一个完整的歌曲项目可能有 5000+ 个音符。每次操作日志不应该包含完整快照——只包含增量操作
  • 定期生成快照(每 100 个操作或每 5 分钟),快照作为"基线"——新用户加入时加载最近的快照 + 快照之后的增量操作

五、总结

AI 音乐协作平台的架构核心是 CRDT(天然适合音乐操作的无冲突合并)+ 操作模型(音符级、参数级、音轨级)+ 版本分支(exploratory mixing)。冲突检测在"同一位置 + 相同音高"触发,合并策略用 LWW(时间戳较新的获胜)。不是 Google Docs 级别的字符协作复制到音乐领域——音乐的粒度是"音符"不是"字符",合并规则完全不同。把 Git 的分支模式引入音乐制作,让"试试别的编曲"变成一个低成本操作。