豆包生成简谱编辑器2026.6.1
<!DOCTYPE html>
<html lang=”zh-CN”>
<head>
<meta charset=”UTF-8″>
<meta name=”viewport” content=”width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no”>
<title>手机简谱编辑器</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:system-ui,-apple-system,sans-serif;}
body{min-height:100vh;display:flex;flex-direction:column;background:#f7f8fa;}
/* 顶部工具栏 Flex */
.toolbar{display:flex;flex-wrap:wrap;gap:8px;padding:12px;background:#2d3748;}
.toolbar button{
min-height:46px;min-width:92px;padding:0 12px;border:none;border-radius:6px;color:#fff;font-size:15px;
}
.btn-meta{background:#3182ce;}
.btn-clear{background:#e53e3e;}
.btn-play{background:#38a169;}
.btn-img{background:#805ad5;}
.btn-json{background:#dd6b20;}
.toolbar button:active{opacity:0.85;}
/* Canvas画布区域 */
.canvas-box{flex:1;padding:10px;overflow:auto;}
#scoreCanvas{width:100%;background:#fff;border:1px solid #cbd5e0;}
/* 底部状态栏 */
.status-bar{padding:14px;background:#fff;border-top:2px solid #e2e8f0;min-height:130px;}
.status-bar h4{margin-bottom:10px;color:#2d3748;}
.status-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:8px 0;}
.status-row label{width:85px;font-size:14px;color:#4a5568;}
.status-row input,.status-row select{flex:1;min-width:130px;padding:9px;border:1px solid #cbd5e0;border-radius:4px;font-size:15px;}
.del-obj-btn{width:100%;margin-top:12px;padding:11px;background:#c53030;color:#fff;border:none;border-radius:6px;font-size:15px;display:none;}
/* 可拖动弹窗遮罩 */
.modal-mask{
position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);display:none;z-index:999;
}
.modal-win{
position:absolute;width:min(94%,440px);background:#fff;border-radius:8px;overflow:hidden;
}
.modal-drag-head{
padding:14px;background:#2d3748;color:#fff;font-weight:bold;cursor:move;
}
.modal-body{padding:16px;}
.modal-line{margin:12px 0;display:flex;flex-direction:column;gap:5px;}
.modal-line input,.modal-line select{padding:10px;border:1px solid #e2e8f0;border-radius:4px;font-size:15px;}
.modal-foot{padding:12px 16px;display:flex;justify-content:flex-end;gap:10px;border-top:1px solid #e2e8f0;}
.modal-foot button{padding:9px 18px;border:none;border-radius:4px;font-size:15px;}
.modal-cancel{background:#a0aec0;color:#fff;}
.modal-save{background:#3182ce;color:#fff;}
/* 隐藏文件上传 */
#file-input{display:none;}
</style>
</head>
<body>
<!– 顶部工具栏 –>
<div class=”toolbar”>
<button class=”btn-meta” id=”btnMeta”>乐谱属性</button>
<button class=”btn-clear” id=”btnClear”>清空乐谱</button>
<button class=”btn-play” id=”btnPlay”>播放乐谱</button>
<button class=”btn-img” id=”btnSaveImg”>保存图片</button>
<button class=”btn-json” id=”btnExport”>导出JSON</button>
<button class=”btn-json” id=”btnImport”>导入JSON</button>
</div>
<!– Canvas简谱绘制区 –>
<div class=”canvas-box”>
<canvas id=”scoreCanvas” height=”850″></canvas>
</div>
<!– 底部属性状态栏 –>
<div class=”status-bar” id=”statusWrap”>
<h4 id=”selectTip”>选中对象:无</h4>
<div id=”statusContent”></div>
<button class=”del-obj-btn” id=”btnDelObj”>删除当前选中对象</button>
</div>
<!– 乐谱属性弹窗 –>
<div class=”modal-mask” id=”metaMask”>
<div class=”modal-win” id=”metaWin”>
<div class=”modal-drag-head” id=”dragBar”>乐谱基础属性(按住拖动窗口)</div>
<div class=”modal-body”>
<div class=”modal-line”>
<label>乐曲标题</label>
<input type=”text” id=”inTitle”>
</div>
<div class=”modal-line”>
<label>作者</label>
<input type=”text” id=”inAuthor”>
</div>
<div class=”modal-line”>
<label>速度BPM</label>
<input type=”number” id=”inSpeed” min=”40″ max=”220″ value=”80″>
</div>
<div class=”modal-line”>
<label>调号</label>
<select id=”inKey”>
<option value=”C”>C大调</option>
<option value=”G”>G大调</option>
<option value=”F”>F大调</option>
<option value=”D”>D大调</option>
<option value=”bB”>降B大调</option>
</select>
</div>
<div class=”modal-line”>
<label>拍号</label>
<select id=”inBeat”>
<option value=”4/4″>4/4拍</option>
<option value=”3/4″>3/4拍</option>
<option value=”2/4″>2/4拍</option>
<option value=”6/8″>6/8拍</option>
</select>
</div>
<div class=”modal-line”>
<label>每行小节数量</label>
<select id=”inLineCount”>
<option>1</option><option>2</option><option>3</option><option selected>4</option>
<option>5</option><option>6</option><option>7</option><option>8</option>
</select>
</div>
</div>
<div class=”modal-foot”>
<button class=”modal-cancel” id=”metaClose”>关闭</button>
<button class=”modal-save” id=”metaSave”>保存设置</button>
</div>
</div>
</div>
<input type=”file” id=”file-input” accept=”.json”>
<script>
// ====================== 一、五层面向对象数据模型 ======================
/** 音符类:最低层级 */
class Note {
constructor(pitch=1, duration=1){
this.id = Date.now() + Math.random();
this.pitch = pitch; // 音高1~7
this.duration = duration; // 时值
this.playFlash = false; // 播放闪烁标记
}
clone(){return new Note(this.pitch, this.duration);}
}
/** 拍子类:包含多个音符 */
class Beat {
constructor(){
this.id = Date.now() + Math.random();
this.notes = [new Note()]; // 至少保留1个音符
}
addNote(){this.notes.push(new Note());}
removeNote(idx){if(this.notes.length>1) this.notes.splice(idx,1);}
}
/** 小节类:包含多个拍子 */
class Measure {
constructor(){
this.id = Date.now() + Math.random();
this.beats = [new Beat()];
}
addBeat(){this.beats.push(new Beat());}
removeBeat(idx){if(this.beats.length>1) this.beats.splice(idx,1);}
}
/** 段落类:包含多个小节 */
class Section {
constructor(name=”主歌”){
this.id = Date.now() + Math.random();
this.name = name;
this.measures = [new Measure()];
this.gapY = 45; // 段落垂直间距
}
addMeasure(){this.measures.push(new Measure());}
removeMeasure(idx){if(this.measures.length>1) this.measures.splice(idx,1);}
}
/** 歌曲顶层类:全局唯一根对象 */
class Song {
constructor(){
// 元数据
this.meta = {
title:”无名乐曲”,
author:”佚名”,
speed:80,
key:”C”,
beat:”4/4″,
lineMeasureCount:4,
version:”1.0″
};
this.sections = [new Section()]; // 至少一个段落
}
addSection(name=”新段落”){this.sections.push(new Section(name));}
removeSection(idx){if(this.sections.length>1) this.sections.splice(idx,1);}
clearAll(){this.sections = [new Section()];}
}
// ====================== 二、全局变量与DOM缓存 ======================
const canvas = document.getElementById(“scoreCanvas”);
const ctx = canvas.getContext(“2d”);
const selectTip = document.getElementById(“selectTip”);
const statusContent = document.getElementById(“statusContent”);
const btnDelObj = document.getElementById(“btnDelObj”);
const metaMask = document.getElementById(“metaMask”);
const dragBar = document.getElementById(“dragBar”);
const fileInput = document.getElementById(“file-input”);
let song = new Song();
let selectedItem = null; // 当前选中 {type, obj, parent, index, x,y,w,h}
let audioCtx = null;
let layoutList = []; // 所有渲染元素坐标集合
// 层级配色
const colorScheme = {
song: {bg:”#edf2f7″, stroke:”#4a5568″},
section: {bg:”#ebf8ff”, stroke:”#3182ce”},
measure: {bg:”#fffaf0″, stroke:”#dd6b20″},
beat: {bg:”#fff5f5″, stroke:”#e53e3e”},
note: {bg:”#faf5ff”, stroke:”#805ad5″},
select: “#dc2626”
};
// 弹窗拖拽状态
let dragState = {drag:false, offsetX:0, offsetY:0};
// ====================== 三、弹窗拖拽逻辑 ======================
dragBar.addEventListener(“mousedown”, e=>{
dragState.drag = true;
const rect = metaWin.getBoundingClientRect();
dragState.offsetX = e.clientX – rect.left;
dragState.offsetY = e.clientY – rect.top;
});
document.addEventListener(“mousemove”, e=>{
if(!dragState.drag) return;
metaWin.style.left = (e.clientX – dragState.offsetX) + “px”;
metaWin.style.top = (e.clientY – dragState.offsetY) + “px”;
});
document.addEventListener(“mouseup”, ()=>dragState.drag=false);
// ====================== 四、工具栏按钮绑定 ======================
// 乐谱属性弹窗
document.getElementById(“btnMeta”).onclick = ()=>{
metaMask.style.display = “block”;
// 回填数据
document.getElementById(“inTitle”).value = song.meta.title;
document.getElementById(“inAuthor”).value = song.meta.author;
document.getElementById(“inSpeed”).value = song.meta.speed;
document.getElementById(“inKey”).value = song.meta.key;
document.getElementById(“inBeat”).value = song.meta.beat;
document.getElementById(“inLineCount”).value = song.meta.lineMeasureCount;
};
document.getElementById(“metaClose”).onclick = ()=> metaMask.style.display = “none”;
document.getElementById(“metaSave”).onclick = ()=>{
song.meta.title = document.getElementById(“inTitle”).value;
song.meta.author = document.getElementById(“inAuthor”).value;
song.meta.speed = Number(document.getElementById(“inSpeed”).value);
song.meta.key = document.getElementById(“inKey”).value;
song.meta.beat = document.getElementById(“inBeat”).value;
song.meta.lineMeasureCount = Number(document.getElementById(“inLineCount”).value);
metaMask.style.display = “none”;
renderCanvas();
};
// 清空乐谱
document.getElementById(“btnClear”).onclick = ()=>{
if(confirm(“确认清空全部乐谱数据?”)){
song.clearAll();
selectedItem = null;
refreshStatus();
renderCanvas();
}
};
// 播放乐谱
document.getElementById(“btnPlay”).onclick = playWholeScore;
// 保存画布为图片
document.getElementById(“btnSaveImg”).onclick = ()=>{
const a = document.createElement(“a”);
a.download = song.meta.title + “_简谱.png”;
a.href = canvas.toDataURL(“image/png”);
a.click();
};
// 导出JSON
document.getElementById(“btnExport”).onclick = ()=>{
const jsonStr = JSON.stringify(song, null, 2);
const blob = new Blob([jsonStr], {type:”application/json”});
const a = document.createElement(“a”);
a.href = URL.createObjectURL(blob);
a.download = song.meta.title + “_乐谱.json”;
a.click();
};
// 导入JSON
document.getElementById(“btnImport”).onclick = ()=> fileInput.click();
fileInput.onchange = e=>{
const file = e.target.files[0];
if(!file) return;
const reader = new FileReader();
reader.onload = res=>{
try{
const raw = JSON.parse(res.target.result);
// 还原类实例
song = Object.assign(new Song(), raw);
song.sections = song.sections.map(s=>{
const sec = Object.assign(new Section(), s);
sec.measures = sec.measures.map(m=>{
const mes = Object.assign(new Measure(), m);
mes.beats = mes.beats.map(b=>{
const bt = Object.assign(new Beat(), b);
bt.notes = bt.notes.map(n=>Object.assign(new Note(),n));
return bt;
});
return mes;
});
return sec;
});
selectedItem = null;
refreshStatus();
renderCanvas();
alert(“乐谱导入成功”);
}catch(err){
alert(“JSON文件解析失败:”+err.message);
}
};
reader.readAsText(file);
fileInput.value = “”;
};
// 删除选中对象
btnDelObj.onclick = deleteSelected;
// ====================== 五、Canvas渲染核心 ======================
const padding = 12;
const blockW = 130;
const blockH = 52;
function renderCanvas(){
ctx.clearRect(0,0,canvas.width,canvas.height);
layoutList = [];
let curY = padding;
// 绘制歌曲根容器
drawBlock(“song”, song, padding, curY, canvas.width-padding*2, 85,
`【乐曲】${song.meta.title} | ${song.meta.author} ${song.meta.beat} ${song.meta.speed}BPM`);
layoutList.push({type:”song”, obj:song, parent:null, index:0, x:padding, y:curY, w:canvas.width-padding*2, h:85});
curY += 95;
// 遍历段落
song.sections.forEach((sec, sIdx)=>{
let curX = padding;
// 段落外层框
drawBlock(“section”, sec, curX, curY, canvas.width-padding*2, blockH, `段落${sIdx+1}:${sec.name}`);
layoutList.push({type:”section”, obj:sec, parent:song, index:sIdx, x:curX, y:curY, w:canvas.width-padding*2, h:blockH});
curY += blockH + 12;
// 段落小节循环
sec.measures.forEach((mes, mIdx)=>{
drawBlock(“measure”, mes, curX, curY, blockW, blockH, `小节${mIdx+1}`);
layoutList.push({type:”measure”, obj:mes, parent:sec, index:mIdx, x:curX, y:curY, w:blockW, h:blockH});
let beatY = curY + 60;
// 拍子循环
mes.beats.forEach((bt, bIdx)=>{
drawBlock(“beat”, bt, curX, beatY, blockW, blockH, `拍子${bIdx+1}`);
layoutList.push({type:”beat”, obj:bt, parent:mes, index:bIdx, x:curX, y:beatY, w:blockW, h:blockH});
let noteY = beatY + 60;
// 音符循环
bt.notes.forEach((note, nIdx)=>{
const flashTip = note.playFlash ? “🔴播放中” : “”;
drawBlock(“note”, note, curX, noteY, blockW, blockH, `音符${nIdx+1} 音高${note.pitch} ${flashTip}`);
layoutList.push({type:”note”, obj:note, parent:bt, index:nIdx, x:curX, y:noteY, w:blockW, h:blockH});
noteY += blockH + 10;
});
beatY += blockH + 10;
});
curX += blockW + 10;
// 每行小节数换行
if((mIdx+1) % song.meta.lineMeasureCount === 0){
curX = padding;
curY += 200;
}
});
curY += sec.gapY;
});
}
// 绘制单个层级矩形块
function drawBlock(type, obj, x,y,w,h,text){
const cfg = colorScheme[type];
ctx.fillStyle = cfg.bg;
ctx.fillRect(x,y,w,h);
// 选中加粗红边框
if(selectedItem && selectedItem.obj === obj){
ctx.strokeStyle = colorScheme.select;
ctx.lineWidth = 3;
}else{
ctx.strokeStyle = cfg.stroke;
ctx.lineWidth = 2;
}
ctx.strokeRect(x,y,w,h);
// 文字
ctx.fillStyle = “#1a202c”;
ctx.font = “14px system-ui”;
ctx.fillText(text, x+10, y+24);
// 选中显示右上角加号添加按钮
if(selectedItem && selectedItem.obj === obj && type !== “note”){
ctx.fillStyle = “#38a169”;
ctx.font = “bold 20px sans-serif”;
ctx.fillText(“+”, x + w – 24, y + 26);
}
}
// ====================== 六、Canvas点击/触摸拾取 ======================
canvas.addEventListener(“touchstart”, e=>{
e.preventDefault();
const t = e.touches[0];
clickHandle(t.clientX, t.clientY);
});
canvas.addEventListener(“click”, e=>clickHandle(e.clientX, e.clientY));
function clickHandle(pageX, pageY){
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = (pageX – rect.left) * scaleX;
const y = (pageY – rect.top) * scaleY;
// 反向遍历优先选中内层音符
for(let i=layoutList.length-1;i>=0;i–){
const item = layoutList[i];
if(x >= item.x && x <= item.x+item.w && y >= item.y && y <= item.y+item.h){
// 判断点击加号新增子对象
const addX = item.x + item.w – 26;
const addY = item.y;
if(x >= addX && y <= addY+32){
addChildItem(item);
return;
}
// 选中当前对象
selectedItem = item;
refreshStatus();
renderCanvas();
return;
}
}
// 空白取消选中
selectedItem = null;
refreshStatus();
renderCanvas();
}
// ====================== 七、层级增删逻辑 ======================
function addChildItem(item){
const {type, obj} = item;
switch(type){
case “song”: song.addSection(); break;
case “section”: obj.addMeasure(); break;
case “measure”: obj.addBeat(); break;
case “beat”: obj.addNote(); break;
case “note”: return;
}
renderCanvas();
}
function deleteSelected(){
if(!selectedItem) return;
const {type, parent, index} = selectedItem;
switch(type){
case “song”: alert(“乐曲根对象无法删除”); return;
case “section”: parent.removeSection(index); break;
case “measure”: parent.removeMeasure(index); break;
case “beat”: parent.removeBeat(index); break;
case “note”: parent.removeNote(index); break;
}
selectedItem = null;
refreshStatus();
renderCanvas();
}
// ====================== 八、底部状态栏渲染与编辑 ======================
function refreshStatus(){
if(!selectedItem){
selectTip.textContent = “选中对象:无”;
statusContent.innerHTML = “”;
btnDelObj.style.display = “none”;
return;
}
const {type, obj} = selectedItem;
btnDelObj.style.display = “block”;
let html = “”;
switch(type){
case “song”:
selectTip.textContent = “选中:乐曲根对象”;
html = `
<div class=”status-row”><label>标题</label><input id=”st_title” value=”${song.meta.title}”></div>
<div class=”status-row”><label>作者</label><input id=”st_author” value=”${song.meta.author}”></div>
<div class=”status-row”><label>速度BPM</label><input type=”number” id=”st_speed” value=”${song.meta.speed}”></div>
`;
break;
case “section”:
selectTip.textContent = “选中:段落”;
html = `
<div class=”status-row”><label>段落名称</label><input id=”st_secName” value=”${obj.name}”></div>
<div class=”status-row”><label>段落间距</label><input type=”number” id=”st_secGap” value=”${obj.gapY}”></div>
`;
break;
case “measure”:
selectTip.textContent = “选中:小节”;
html = `<div class=”status-row”><label>唯一ID</label><input disabled value=”${obj.id}”></div>`;
break;
case “beat”:
selectTip.textContent = “选中:拍子”;
html = `<div class=”status-row”><label>唯一ID</label><input disabled value=”${obj.id}”></div>`;
break;
case “note”:
selectTip.textContent = “选中:音符”;
html = `
<div class=”status-row”><label>音高(1-7)</label><input type=”number” min=”1″ max=”7″ id=”st_pitch” value=”${obj.pitch}”></div>
<div class=”status-row”><label>时值</label><input type=”number” step=”0.5″ id=”st_dur” value=”${obj.duration}”></div>
`;
break;
}
statusContent.innerHTML = html;
bindStatusEdit();
}
// 绑定状态栏输入实时修改数据
function bindStatusEdit(){
if(!selectedItem) return;
const {type, obj} = selectedItem;
switch(type){
case “song”:
document.getElementById(“st_title”).oninput = e=>song.meta.title = e.target.value;
document.getElementById(“st_author”).oninput = e=>song.meta.author = e.target.value;
document.getElementById(“st_speed”).oninput = e=>song.meta.speed = Number(e.target.value);
break;
case “section”:
document.getElementById(“st_secName”).oninput = e=>obj.name = e.target.value;
document.getElementById(“st_secGap”).oninput = e=>obj.gapY = Number(e.target.value);
break;
case “note”:
document.getElementById(“st_pitch”).oninput = e=>obj.pitch = Math.max(1,Math.min(7,Number(e.target.value)));
document.getElementById(“st_dur”).oninput = e=>obj.duration = Number(e.target.value);
break;
}
}
// ====================== 九、WebAudio播放逻辑 ======================
function playWholeScore(){
if(!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// 清空所有播放闪烁
walkAllNote(n=>n.playFlash=false);
renderCanvas();
const bpm = song.meta.speed;
const beatUnit = 60 / bpm;
let delaySec = 0;
song.sections.forEach(sec=>{
sec.measures.forEach(mes=>{
mes.beats.forEach(bt=>{
bt.notes.forEach(note=>{
setTimeout(()=>{
note.playFlash = true;
renderCanvas();
playTone(note.pitch, note.duration * beatUnit);
setTimeout(()=>{
note.playFlash = false;
renderCanvas();
}, note.duration * beatUnit * 1000 – 100);
}, delaySec * 1000);
delaySec += note.duration * beatUnit;
});
});
});
});
}
// 遍历全部音符
function walkAllNote(cb){
song.sections.forEach(s=>s.measures.forEach(m=>m.beats.forEach(b=>b.notes.forEach(n=>cb(n)))));
}
// 单音播放
function playTone(pitch, dur){
const freqMap = [0,261.63,293.66,329.63,349.23,392,440,493.88];
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = “sine”;
osc.frequency.value = freqMap[pitch];
gain.gain.setValueAtTime(0.22, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + dur);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + dur);
}
// ====================== 十、窗口自适应初始化 ======================
function resizeCanvas(){
canvas.width = document.querySelector(“.canvas-box”).clientWidth;
renderCanvas();
}
window.addEventListener(“resize”, resizeCanvas);
resizeCanvas();
</script>
</body>
</html>
夜雨聆风