- 多轨道时间线: 视频/图片/音频/文字, 拖拽/修剪/分割/复制/删除 - 12款滤镜特效 + 4种转场 + 4种文字动画 - 6款程序化原创配乐(Web Audio 实时合成, 零版权风险) - Canvas 合成 + MediaRecorder 实时导出 WebM(VP9+Opus) - 账号体系 + 每日导出额度 + 企业/AI短剧付费升级 + 管理后台 - 项目自动保存/缩略图, 移动端自适应 - 个人免费每天30次导出, 企业¥299/月 不限量, AI短剧¥499/月 不限量
204 lines
8.0 KiB
JavaScript
204 lines
8.0 KiB
JavaScript
// FreeCut 程序化配乐生成器 —— 全部在浏览器端合成,无版权风险,人人免费使用
|
|
// 每种情绪生成一段可循环的纯音乐,作为内置配乐库
|
|
const MusicEngine = (() => {
|
|
const cache = {};
|
|
let _ctx = null;
|
|
function ctx() {
|
|
if (!_ctx) _ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
return _ctx;
|
|
}
|
|
|
|
// 确定性伪随机(保证同一情绪每次生成一样,可循环)
|
|
function makeRng(seed) {
|
|
let s = seed >>> 0;
|
|
return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
|
|
}
|
|
|
|
// 定义:BPM / 根音(midi) / 调式 / 和弦进行(每小节1个和弦根音, 类型) / 琶音间隔 / 节奏感
|
|
const MOODS = {
|
|
warm: { name: '温馨治愈', bpm: 74, root: 60, scale: 'major', prog: [[60,'maj7'],[65,'maj7'],[67,'maj7'],[62,'maj7']], arp: 'full', feel: 'pad' },
|
|
fresh: { name: '清新活力', bpm: 100, root: 62, scale: 'major', prog: [[62,'maj7'],[60,'maj7'],[67,'maj7'],[65,'maj7']], arp: 'up', feel: 'pluck' },
|
|
epic: { name: '激昂大片', bpm: 120, root: 57, scale: 'minor', prog: [[57,'min'],[55,'min'],[60,'min'],[55,'min']], arp: 'drive', feel: 'epic' },
|
|
night: { name: '深夜思绪', bpm: 70, root: 57, scale: 'minor', prog: [[57,'min7'],[62,'maj7'],[55,'min7'],[60,'maj7']], arp: 'soft', feel: 'lofi' },
|
|
tech: { name: '科技未来', bpm: 110, root: 64, scale: 'minor', prog: [[64,'min'],[61,'min'],[66,'min'],[59,'min']], arp: 'seq', feel: 'synth' },
|
|
happy: { name: '欢乐节奏', bpm: 118, root: 60, scale: 'major', prog: [[60,'maj'],[65,'maj'],[60,'maj'],[67,'maj']], arp: 'bounce', feel: 'pop' },
|
|
};
|
|
|
|
const MAJOR = [0, 2, 4, 5, 7, 9, 11, 12];
|
|
const MINOR = [0, 2, 3, 5, 7, 8, 10, 12];
|
|
const CHORD = {
|
|
'maj': [0, 4, 7], 'maj7': [0, 4, 7, 11], 'min': [0, 3, 7],
|
|
'min7': [0, 3, 7, 10], 'sus': [0, 5, 7], 'dim': [0, 3, 6],
|
|
};
|
|
|
|
function scaleOf(mood) { return mood.scale === 'minor' ? MINOR : MAJOR; }
|
|
|
|
function generate(key, bars = 8) {
|
|
const mood = MOODS[key];
|
|
if (!mood) return null;
|
|
const ac = ctx();
|
|
const rng = makeRng(hashStr(key));
|
|
const spb = 60 / mood.bpm; // 每拍秒数
|
|
const secsPerBar = spb * 4;
|
|
const total = secsPerBar * bars;
|
|
const sr = ac.sampleRate;
|
|
const buf = ac.createBuffer(2, Math.ceil(total * sr), sr);
|
|
const sc = scaleOf(mood);
|
|
const rootTone = mood.root;
|
|
|
|
// 和弦根音序列
|
|
const prog = mood.prog;
|
|
const chords = [];
|
|
for (let i = 0; i < bars; i++) {
|
|
const [r, type] = prog[i % prog.length];
|
|
const tones = CHORD[type].map(iv => r + 12 + iv); // 中音区
|
|
chords.push(tones);
|
|
}
|
|
|
|
// 主旋律音阶池
|
|
const pool = sc.map(iv => rootTone + 12 + iv).concat(sc.map(iv => rootTone + 24 + iv));
|
|
const pool2 = sc.map(iv => rootTone + 12 + iv);
|
|
|
|
// 旋律事件(每小节2-4个音)
|
|
const melody = [];
|
|
for (let b = 0; b < bars; b++) {
|
|
const n = mood.feel === 'epic' ? 2 : mood.feel === 'lofi' ? 2 : 3 + (rng() > 0.5 ? 1 : 0);
|
|
for (let i = 0; i < n; i++) {
|
|
const beatPos = (i / n) * 4;
|
|
const t = (b + beatPos / 4) * secsPerBar;
|
|
let note;
|
|
if (mood.feel === 'epic') {
|
|
note = chords[b][0] + 12; // 根音高八度,宏大
|
|
} else if (mood.feel === 'lofi') {
|
|
note = pool[Math.floor(rng() * pool.length)]; // 慵懒随机
|
|
} else {
|
|
note = pool2[Math.floor(rng() * pool2.length)];
|
|
}
|
|
const dur = (spb * 4) / n * (0.75 + rng() * 0.4);
|
|
melody.push({ t, note, dur, vel: 0.5 + rng() * 0.3 });
|
|
}
|
|
}
|
|
|
|
// ---------- 渲染 ----------
|
|
const L = buf.getChannelData(0), R = buf.getChannelData(1);
|
|
const N = L.length;
|
|
const add = (ch, from, dur, fn) => {
|
|
const s0 = Math.max(0, Math.floor(from * sr));
|
|
const n = Math.min(N - s0, Math.ceil(dur * sr));
|
|
for (let i = 0; i < n; i++) {
|
|
const t = i / sr;
|
|
const v = fn(t);
|
|
ch[s0 + i] += v;
|
|
}
|
|
};
|
|
|
|
// 底鼓/军鼓简单节奏(确定性)
|
|
for (let b = 0; b < bars; b++) {
|
|
const t0 = b * secsPerBar;
|
|
// 底鼓:每小节 4 拍
|
|
for (let k = 0; k < 4; k++) {
|
|
const t = t0 + k * spb;
|
|
add(L, t, 0.25, tt => Math.sin(2 * Math.PI * (90 - tt * 160) * tt) * Math.exp(-tt * 14) * 0.5);
|
|
add(R, t, 0.25, tt => Math.sin(2 * Math.PI * (90 - tt * 160) * tt) * Math.exp(-tt * 14) * 0.5);
|
|
}
|
|
// 军鼓/拍手:2、4拍,激昂时用
|
|
if (mood.feel === 'epic' || mood.feel === 'pop' || mood.feel === 'drive') {
|
|
for (const k of [1, 3]) {
|
|
const t = t0 + k * spb;
|
|
const noise = makeNoise(rng());
|
|
add(L, t, 0.18, tt => noise(Math.floor(tt * 20000)) * Math.exp(-tt * 30) * 0.35);
|
|
add(R, t, 0.18, tt => noise(Math.floor(tt * 20000)) * Math.exp(-tt * 30) * 0.35);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 贝斯
|
|
for (let b = 0; b < bars; b++) {
|
|
const chRoot = mood.prog[b % mood.prog.length][0];
|
|
for (let k = 0; k < 4; k++) {
|
|
const t = b * secsPerBar + k * spb;
|
|
const f = midiToFreq(chRoot - 12);
|
|
add(L, t, spb * 0.9, tt => Math.sin(2 * Math.PI * f * tt) * Math.exp(-tt * 3.2) * 0.30);
|
|
add(R, t, spb * 0.9, tt => Math.sin(2 * Math.PI * f * tt) * Math.exp(-tt * 3.2) * 0.30);
|
|
}
|
|
}
|
|
|
|
// 和弦铺底
|
|
for (let b = 0; b < bars; b++) {
|
|
const tones = chords[b];
|
|
const t = b * secsPerBar;
|
|
for (const n of tones) {
|
|
const f = midiToFreq(n);
|
|
add(L, t, secsPerBar, tt => detuneSaw(tt, f) * env(tt, secsPerBar, 0.8, 0.5) * 0.055);
|
|
add(R, t, secsPerBar, tt => detuneSaw(tt, f * 1.003) * env(tt, secsPerBar, 0.8, 0.5) * 0.055);
|
|
}
|
|
}
|
|
|
|
// 主旋律
|
|
for (const m of melody) {
|
|
const f = midiToFreq(m.note);
|
|
if (mood.feel === 'epic') {
|
|
add(L, m.t, m.dur, tt => detuneSaw(tt, f) * env(tt, m.dur, 0.15, 0.6) * m.vel * 0.22);
|
|
add(R, m.t, m.dur, tt => detuneSaw(tt, f * 1.002) * env(tt, m.dur, 0.15, 0.6) * m.vel * 0.22);
|
|
} else if (mood.feel === 'lofi') {
|
|
add(L, m.t, m.dur, tt => softTone(tt, f) * env(tt, m.dur, 0.2, 0.8) * m.vel * 0.35);
|
|
add(R, m.t, m.dur, tt => softTone(tt, f) * env(tt, m.dur, 0.2, 0.8) * m.vel * 0.35);
|
|
} else {
|
|
// 琶音拨弦
|
|
add(L, m.t, m.dur, tt => pluck(tt, f) * m.vel * 0.30);
|
|
add(R, m.t, m.dur, tt => pluck(tt, f * 1.001) * m.vel * 0.30);
|
|
}
|
|
}
|
|
|
|
// 归一化
|
|
let peak = 0;
|
|
for (let i = 0; i < N; i++) peak = Math.max(peak, Math.abs(L[i]), Math.abs(R[i]));
|
|
if (peak > 0.95) {
|
|
const g = 0.95 / peak;
|
|
for (let i = 0; i < N; i++) { L[i] *= g; R[i] *= g; }
|
|
}
|
|
cache[key] = buf;
|
|
return buf;
|
|
}
|
|
|
|
function hashStr(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; return h; }
|
|
function midiToFreq(m) { return 440 * Math.pow(2, (m - 69) / 12); }
|
|
function env(t, dur, a, r) {
|
|
const rel = Math.max(0.05, dur * r);
|
|
if (t < a) return t / a;
|
|
if (t > dur) return 0;
|
|
return Math.max(0, 1 - (t - a) / rel);
|
|
}
|
|
function detuneSaw(t, f) {
|
|
const p = (t * f) % 1;
|
|
return 2 * p - 1;
|
|
}
|
|
function softTone(t, f) {
|
|
const s = Math.sin(2 * Math.PI * f * t);
|
|
return 0.6 * s + 0.4 * Math.sin(2 * Math.PI * f * 2 * t) * 0.5;
|
|
}
|
|
function pluck(t, f) {
|
|
const d = Math.sin(2 * Math.PI * f * t) * Math.exp(-t * 6);
|
|
const h = Math.sin(2 * Math.PI * f * 2.01 * t) * Math.exp(-t * 16) * 0.4;
|
|
return d + h;
|
|
}
|
|
function makeNoise(rng) {
|
|
const buf = new Float32Array(20000);
|
|
for (let i = 0; i < buf.length; i++) buf[i] = rng() * 2 - 1;
|
|
return idx => buf[Math.abs(idx) % buf.length];
|
|
}
|
|
|
|
// 获取(生成并缓存)配乐 AudioBuffer
|
|
function getBuffer(key) {
|
|
if (cache[key]) return Promise.resolve(cache[key]);
|
|
// 稍等一帧,避免阻塞
|
|
return new Promise(res => setTimeout(() => res(generate(key)), 30));
|
|
}
|
|
|
|
function list() {
|
|
return Object.entries(MOODS).map(([k, v]) => ({ key: k, name: v.name, bpm: v.bpm }));
|
|
}
|
|
|
|
return { list, getBuffer, generate, ctx };
|
|
})();
|