乐于分享
好东西不私藏

豆包生成简谱编辑器2026.6.1

豆包生成简谱编辑器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>