让模型写一段音乐,最容易展示的是一份像模像样的 JSON。真正麻烦的是下一步:第八小节有没有写满?单声道里两个音是否叠在一起?提示词里的占空比有没有进入合成代码?如果这些问题没处理,播放成功只能说明代码没有立刻抛异常。
10 月 22 日,Anthropic 宣布将 Agent Skills 捐赠给 AAIF。对音乐工具来说,我更关心其中可执行的流程约束:生成器可以换,输入谱、校验规则和失败记录应留下。下面先做一个不调用模型的八小节夹具,验证通过后才允许进入 Web Audio。它是芯片风合成,不是 NES 硬件的精确仿真。
先把一份谱的边界写死
本例采用 4/4 拍,每小节 16 个十六分音符位置,共 128 步。四条通道分别是两个脉冲声部、三角波低音和噪声打击。每条通道按单声部处理,因此任何时间重叠都拒绝;需要和弦就使用多条声部,而不是让同一声部偷偷变成复音合成器。
Schema 不只管字段类型。bpm、bars、音域、起止位置和同声部重叠都是合同的一部分。模型给出的未知字段也应被拒绝,否则一个看起来有用、实际上从未消费的 duty 字段会制造“已经控制音色”的错觉。
下面保存为 score.mjs。校验器、确定性噪声和浏览器回放入口都在同一文件中,无第三方依赖。
const channels = ['pulse1', 'pulse2', 'triangle', 'noise']; function keys(o, required, optional = []) { if (!o || typeof o !== 'object' || Array.isArray(o)) throw Error('object required'); if (required.some(k => !Object.hasOwn(o, k)) || Object.keys(o).some(k => !required.includes(k) && !optional.includes(k))) throw Error('fields'); } export function validate(s) { keys(s, ['bpm', 'bars', 'channels']); if (!Number.isFinite(s.bpm) || s.bpm < 40 || s.bpm > 240 || s.bars !== 8) throw Error('tempo/bars'); keys(s.channels, channels); for (const ch of channels) { const events = s.channels[ch]; if (!Array.isArray(events) || events.length > 128) throw Error('events'); let end = 0; for (const e of [...events].sort((x, y) => x.step - y.step)) { const pulse = ch.startsWith('pulse'); keys(e, ch === 'noise' ? ['step', 'len'] : ['step', 'len', 'midi'], pulse ? ['duty'] : []); if (!Number.isInteger(e.step) || !Number.isInteger(e.len) || e.step < 0 || e.len < 1 || e.step + e.len > 128 || e.step < end) throw Error('time/overlap'); if (ch !== 'noise' && (!Number.isInteger(e.midi) || e.midi < 0 || e.midi > 127)) throw Error('pitch'); if (pulse && ![0.125, 0.25, 0.5, 0.75].includes(e.duty ?? 0.5)) throw Error('duty'); end = e.step + e.len; } } return s; } export function noise(n, seed = 0x1ace) { if (!Number.isInteger(n) || n < 1 || n > 192000) throw Error('size'); if (!Number.isInteger(seed) || seed < 1 || seed > 32767) throw Error('seed'); const data = new Float32Array(n); for (let i = 0; i < n; i++) { const bit = (seed ^ (seed >> 1)) & 1; seed = (seed >> 1) | (bit << 14); data[i] = (seed & 1) ? 0.25 : -0.25; } return data; } export async function play(ctx, score) { validate(score); await ctx.resume(); // 必须由页面的用户操作触发此入口 const master = ctx.createGain(); master.gain.value = 0.12; master.connect(ctx.destination); const buffer = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate); buffer.copyToChannel(noise(ctx.sampleRate), 0); const start = ctx.currentTime + 0.1, stepSeconds = 60 / score.bpm / 4; const live = new Set(); for (const ch of channels) for (const e of score.channels[ch]) { const t = start + e.step * stepSeconds, duration = e.len * stepSeconds; const src = ch === 'noise' ? ctx.createBufferSource() : ctx.createOscillator(); if (ch === 'noise') { src.buffer = buffer; src.loop = true; } else { const f = 440 * 2 ** ((e.midi - 69) / 12); src.frequency.value = f; if (ch === 'triangle') src.type = 'triangle'; else { const count = Math.min(32, Math.floor(ctx.sampleRate / (2 * f))); const real = new Float32Array(count + 1), imag = new Float32Array(count + 1); const d = e.duty ?? 0.5; for (let k = 1; k <= count; k++) { real[k] = 2 * Math.sin(2 * Math.PI * k * d) / (Math.PI * k); imag[k] = 2 * (1 - Math.cos(2 * Math.PI * k * d)) / (Math.PI * k); } src.setPeriodicWave(ctx.createPeriodicWave(real, imag)); } } const gain = ctx.createGain(), attack = Math.min(0.003, duration / 4); gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(1, t + attack); gain.gain.setValueAtTime(1, t + duration - attack); gain.gain.linearRampToValueAtTime(0, t + duration); src.connect(gain).connect(master); live.add(src); src.onended = () => { live.delete(src); src.disconnect(); gain.disconnect(); if (!live.size) master.disconnect(); }; src.start(t); src.stop(t + duration); } if (!live.size) master.disconnect(); return () => { for (const src of live) src.stop(ctx.currentTime); }; }回放和生成分别验收
在页面按钮的点击处理器中创建或复用 AudioContext,再调用 play(ctx, score)。这里按音频时钟一次排程,界面动画不负责“准点发声”。浏览器自动播放策略、设备输出延迟和实际音量仍需单独测量。函数接收调用方的上下文,结束后只释放本次节点,不擅自关闭别的组件共享的声卡资源。
脉冲占空比在这里确实进入了傅里叶系数计算;谐波数量也按采样率和音高设上限。不能先随意生成混叠,再声称串一个低通就能找回原来的频率。噪声使用显式 LFSR 状态,不拿线性同余生成器的最低位冒充硬件噪声。不过,寄存器、包络和计时都没有逐项复刻 2A03,音色相似性还需要听审。
图示区分了合法音符、同通道重叠和越出允许音域。它是生成的规则示意图,不是模型输出或频谱实测;图中的音域带可以由具体乐器规则进一步收窄。
给模型的合同可以要求它只返回八小节谱,失败时返回错误说明。工具侧仍要重新校验。不要静默把 MIDI 999 改成 69,也不要把缺失通道补成“生成成功”:这会把模型失败藏进播放器。
本例的本地验证只覆盖结构、时间边界、占空比和噪声确定性。没有调用音乐模型,没有在浏览器录制音频,也没有做主观听感评分。下一轮需要保留原始模型输出、修复前后差异和导出的 WAV,再讨论生成质量。
复现记录
本轮在 Node.js v26.3.0 中运行随文核心逻辑的构造样例,进程退出码为 0。测试输出:
PASS: valid score, missing channel, overlap, pitch, duty, deterministic LFSR, zero-seed rejection将上方 JavaScript 保存为 example.mjs,可用 node example.mjs 加载;浏览器、音频节点、摄像头及外部服务入口另需对应环境。构造样例不等于真实业务效果。
资料与时效
- Donating Agent Skills to the Agentic AI Foundation:2026-10-22。
- Agent Skills Specification:首次发布日期未核实。
- Web Audio API 1.1:2026-09-22。
本稿对应原预排时段;实际发布前须人工复核来源更正、SDK/API 版本及可用性,不据此预言 10 月 24–31 日的新事件。