---
name: music-theory-exam
description: 根据用户需求生成可直接打印的HTML网页版音乐乐理考试试卷。支持自定义难度、题型、考试范围等。默认按大学生课程期末考试水准生成，包含姓名、学号等标准试卷头部信息，所有音乐特殊符号符合业内印刷标准，使用中文输出。当用户需要生成音乐乐理试卷、乐理考试题、音乐理论测试时使用。
---

# 音乐乐理考试试卷生成技能

## 工作流程

1. 分析用户需求（难度、题型、考试范围、题量等）
2. 若无特殊要求，按大学生期末考试水准生成
3. 读取 `references/music-symbols.md` 了解标准音乐符号的 HTML/Unicode 写法
4. 读取 `references/exam-structure.md` 了解标准试卷结构和题型规范
5. 生成完整 HTML 试卷，内嵌 CSS 确保打印效果

## 默认考试规格（无特殊要求时使用）

- **难度**：大学本科音乐学院/师范院校音乐系期末考试水准
- **总分**：100 分
- **时长**：120 分钟
- **题型**：
  1. **音名与组别**：写出音名及组别，或根据组别写出音高
  2. **音程构建**：以指定音为根音构成指定音程
  3. **和弦构建**：以指定音为低音构成指定和弦（三和弦、七和弦及其转位）
  4. **节奏组合**：根据指定拍号进行正确的音值组合并划分小节
  5. **调式音阶**：使用调号写出指定的调式音阶（自然/和声/旋律大小调、五声调式等）
  6. **旋律分析**：分析旋律的调式调性（包含转调分析）
  7. **解决**：写出指定音程或和弦并解决
- **考查范围**：基本乐理全覆盖

## HTML 试卷生成规范

### 页面设置
```css
@page { size: A4; margin: 20mm 15mm 20mm 20mm; }
body { font-family: "SimSun", "宋体", serif; font-size: 10.5pt; }
```

### 必须包含的试卷头部
- 学校名称（居中，大号字）
- 课程名称（如"基本乐理"）
- 考试类型（期末考试/期中考试）
- 学年学期
- 姓名、学号、班级、成绩填写栏（表格形式）
- 注意事项

---

## 五线谱渲染——完整规范（含所有已知 Bug 及修复方案）

### 1. VexFlow CDN 加载策略

**【已知问题】** Edge 浏览器的"跟踪防护"功能会拦截通过 `<script src="...">` 标签直接引入的 CDN 脚本，导致 VexFlow 加载失败，五线谱完全空白。

**【正确做法】** 必须使用 JavaScript **动态注入 `<script>` 标签**的方式加载，并设置 jsdelivr → unpkg 双 CDN 回退链：

```javascript
function loadScript(src, onSuccess, onFail) {
  var s = document.createElement('script');
  s.src = src;
  s.onload = onSuccess;
  s.onerror = onFail;
  document.head.appendChild(s);
}

var CDN1 = 'https://cdn.jsdelivr.net/npm/vexflow@4.2.2/build/cjs/vexflow.js';
var CDN2 = 'https://unpkg.com/vexflow@4.2.2/build/cjs/vexflow.js';

loadScript(CDN1, renderAll, function() {
  loadScript(CDN2, renderAll, function() {
    // 两个 CDN 都失败时显示错误提示
    renderAll();
  });
});
```

**【禁止】** 不得在 `<head>` 或 `<body>` 中使用 `<script src="...">` 静态标签引入 VexFlow。

---

### 2. 脚本执行顺序

**【已知问题】** 若在各题目 HTML 元素旁边写内联 `<script>` 调用渲染函数，此时 VexFlow 尚未加载，`window.addRenderTask` 等全局函数还是 `undefined`，所有渲染任务被静默丢弃，五线谱完全空白。

**【正确做法】** 所有渲染代码必须集中写在 `</body>` 前的**一个** `<script>` 块中，在 VexFlow 加载完成的回调里统一执行。HTML 中的五线谱容器只放空 `<div>`，不放任何 `<script>`。

```html
<!-- HTML 中只放容器 -->
<div id="vf-q1" class="staff-container"></div>

<!-- 所有渲染逻辑集中在 body 底部 -->
<script>
(function() {
  function renderAll() {
    // 在这里统一渲染所有五线谱
  }
  loadScript(CDN1, renderAll, ...);
})();
</script>
```

---

### 3. 禁止使用 EasyScore / Factory / System API 渲染多音符

**【已知问题】** `score.voice(score.notes('C4/w, A3/w, ...'))` 会触发 `Too many ticks` 错误：
- EasyScore 的 `voice()` 默认按 4/4 拍严格验证，5 个全音符 = 20 拍，超出 4 拍限制
- `system.addStave({})` 传空对象时内部遍历 `voices` 属性报 `Cannot read properties of undefined (reading 'forEach')`

**【正确做法】** 必须使用 VexFlow **低级 API**：`VF.Stave` + `VF.StaveNote` + `VF.Voice` + `VF.Formatter`，并对 Voice 调用 `.setStrict(false)` 关闭拍号验证：

```javascript
var VF = Vex.Flow;

// 创建渲染上下文
var div = document.getElementById('vf-q1');
div.innerHTML = '';  // 重新渲染前必须清空
var renderer = new VF.Renderer(div, VF.Renderer.Backends.SVG);
renderer.resize(width, height);
var ctx = renderer.getContext();

// 创建谱表
var stave = new VF.Stave(x, y, staveWidth);
stave.addClef('treble').setContext(ctx).draw();

// 创建音符
var notes = [
  new VF.StaveNote({ clef: 'treble', keys: ['c/4'], duration: 'w' }),
  new VF.StaveNote({ clef: 'treble', keys: ['a/3'], duration: 'w' }),
  // ...
];

// 用 Voice + Formatter 渲染，setStrict(false) 关闭拍号验证
var voice = new VF.Voice({ num_beats: 5, beat_value: 1 }).setStrict(false);
voice.addTickables(notes);
new VF.Formatter().joinVoices([voice]).format([voice], staveWidth - 80);
voice.draw(ctx, stave);
```

**【空谱表（只有谱号/调号，无音符）】** 直接调用 stave 方法，不经过 Voice/System：

```javascript
var stave = new VF.Stave(10, 10, width - 20);
stave.addClef('treble').addKeySignature('E').setContext(ctx).draw();
// 不需要 Voice，直接结束
```

---

### 4. SVG 宽度必须动态适配容器，禁止硬编码

**【已知问题】** 硬编码 `width: 700` 时，若页面容器实际宽度不同（如窗口缩放、打印时 A4 宽度），SVG 与容器宽度不一致，导致：
- 五线谱右侧超出容器或留有大量空白
- 答题标签与音符位置错位

**【正确做法】** 渲染时动态读取容器宽度：

```javascript
function makeCtx(id, height) {
  var w = document.getElementById(id).offsetWidth || 700;
  var div = document.getElementById(id);
  div.innerHTML = '';  // 重新渲染前清空旧 SVG
  var renderer = new VF.Renderer(div, VF.Renderer.Backends.SVG);
  renderer.resize(w, height);
  return { ctx: renderer.getContext(), w: w };
}
```

谱表宽度 = 容器宽度 - 左右边距：`var staveW = w - 20;`

---

### 5. 打印时五线谱必须重新渲染

**【已知问题】** SVG 在页面加载时按屏幕宽度渲染，打印时浏览器切换到 A4 纸张尺寸（宽度变窄），但 SVG 不会自动重绘，导致打印预览中五线谱右侧被截断。

**【正确做法】** 将所有渲染逻辑封装为接受宽度参数的函数 `renderAllWithWidth(W_override)`，并监听打印事件：

```javascript
// A4 打印可用宽度：210mm - 左边距20mm - 右边距15mm = 175mm ≈ 621px（96dpi）
var PRINT_WIDTH = 621;

function renderAllWithWidth(W_override) {
  // W_override 为 null 时用 offsetWidth，否则用传入值
  function getW(id) {
    if (W_override) return W_override;
    return document.getElementById(id).offsetWidth || 700;
  }
  // ... 所有渲染逻辑
}

function renderAll() {
  renderAllWithWidth(null);  // 屏幕渲染

  window.addEventListener('beforeprint', function() {
    renderAllWithWidth(PRINT_WIDTH);  // 打印前按 A4 宽度重渲染
  });
  window.addEventListener('afterprint', function() {
    renderAllWithWidth(null);  // 打印后恢复屏幕宽度
  });
}
```

---

### 6. 音符高度空间：谱表 y 偏移必须为极端音区留足空间

**【已知问题】** 若谱表 y 偏移过小（如 `y=25`），高音区（如 f/5）上方或低音区（如 e/3）下方的加线会超出 SVG 画布，导致音符被裁剪，只显示一半。

**【规则】**：
- 高音谱号中，`e/3` 及以下需要 3 条加线，音符头在五线谱下方约 30px
- 高音谱号中，`f/5` 及以上需要 1 条加线，音符头在五线谱上方约 15px
- 低音谱号中，`c/2` 及以下需要加线，需额外底部空间

**【正确做法】** 根据题目中音符的音区范围调整谱表 y 偏移和 SVG 高度：

| 音区范围 | 推荐 stave y | 推荐 SVG 高度 |
|---------|------------|------------|
| 仅中音区（c/4 附近） | 20 | 110 |
| 含高音区（到 g/5） | 30 | 130 |
| 含低音区（到 e/3） | 45 | 175 |
| 同时含高音和低音区 | 50 | 190 |

---

### 7. 答题标签与音符对齐：必须用绝对定位 + getAbsoluteX()

**【已知问题】** 用 `display: flex; justify-content: space-around` 均匀分布答题标签，无法与音符实际位置对齐（因为谱号、调号、拍号会占用不同宽度，音符间距也不均匀）。

**【正确做法】** 答题标签容器用 `position: relative`，每个标签用 `position: absolute`，在渲染完成后读取每个音符的 `getAbsoluteX()` 精确设置 `left` 值：

```html
<!-- HTML 结构 -->
<div id="vf-q1" class="staff-container"></div>
<div id="q1-brackets" style="position:relative; height:28px; margin-top:4px;">
  <span style="font-family:'Times New Roman',serif;">( &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; )</span>
  <span style="font-family:'Times New Roman',serif;">( &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; )</span>
  <!-- 每个音符对应一个 span -->
</div>
```

```javascript
// 渲染完成后对齐
function alignLabels(labelId, notes) {
  var container = document.getElementById(labelId);
  if (!container) return;
  var items = container.querySelectorAll('span');
  notes.forEach(function(note, i) {
    if (items[i]) {
      items[i].style.position = 'absolute';
      items[i].style.left = (note.getAbsoluteX() - 22) + 'px';
      items[i].style.width = '60px';
      items[i].style.textAlign = 'center';
    }
  });
}
```

---

### 8. 重新渲染前必须清空容器

**【已知问题】** 打印事件触发重新渲染时，若不清空容器，新旧两个 SVG 会叠加显示。

**【正确做法】** 每次渲染前执行 `div.innerHTML = ''`（已在 `makeCtx` 函数中统一处理）。

---

### 9. 完整代码架构模板

生成试卷时必须严格遵循以下架构：

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <style>
    @page { size: A4; margin: 20mm 15mm 20mm 20mm; }
    /* ... 其他样式 ... */
    .staff-container { width: 100%; overflow: visible; margin: 10px 0; position: relative; }
    /* 禁止给 staff-container 设置固定 min-height，会导致空白过多 */
  </style>
  <!-- 禁止在此处用 <script src> 引入 VexFlow -->
</head>
<body>

  <!-- 试卷内容：五线谱容器只放空 div，不放 script -->
  <div id="vf-q1" class="staff-container"></div>
  <div id="q1-brackets" style="position:relative; height:28px; margin-top:4px;">
    <span>...</span>
  </div>

  <!-- 所有渲染逻辑集中在 body 底部 -->
  <script>
  (function() {
    var PRINT_WIDTH = 621;

    function renderAllWithWidth(W_override) {
      if (typeof Vex === 'undefined') return;
      var VF = Vex.Flow;

      function getW(id) {
        if (W_override) return W_override;
        return document.getElementById(id).offsetWidth || 700;
      }

      function makeCtx(id, h) {
        var w = getW(id);
        var div = document.getElementById(id);
        div.innerHTML = '';
        var renderer = new VF.Renderer(div, VF.Renderer.Backends.SVG);
        renderer.resize(w, h);
        return { ctx: renderer.getContext(), w: w };
      }

      function alignLabels(labelId, notes) {
        var container = document.getElementById(labelId);
        if (!container) return;
        var items = container.querySelectorAll('span');
        notes.forEach(function(note, i) {
          if (items[i]) {
            items[i].style.position = 'absolute';
            items[i].style.left = (note.getAbsoluteX() - 22) + 'px';
            items[i].style.width = '60px';
            items[i].style.textAlign = 'center';
          }
        });
      }

      // 每道题的渲染函数
      try {
        var r = makeCtx('vf-q1', 175);  // 含低音区，高度 175
        var stave = new VF.Stave(10, 45, r.w - 20);  // 含低音区，y=45
        stave.addClef('treble').setContext(r.ctx).draw();
        var notes = [ /* ... */ ];
        var voice = new VF.Voice({ num_beats: notes.length, beat_value: 1 }).setStrict(false);
        voice.addTickables(notes);
        new VF.Formatter().joinVoices([voice]).format([voice], r.w - 80);
        voice.draw(r.ctx, stave);
        alignLabels('q1-brackets', notes);
      } catch(e) { console.error('vf-q1:', e); }
    }

    function renderAll() {
      if (typeof Vex === 'undefined') {
        document.body.insertAdjacentHTML('afterbegin',
          '<p style="color:red;border:1px dashed red;padding:8px;">VexFlow 加载失败，请检查网络后刷新。</p>'
        );
        return;
      }
      renderAllWithWidth(null);
      window.addEventListener('beforeprint', function() { renderAllWithWidth(PRINT_WIDTH); });
      window.addEventListener('afterprint', function() { renderAllWithWidth(null); });
    }

    function loadScript(src, ok, fail) {
      var s = document.createElement('script');
      s.src = src; s.onload = ok; s.onerror = fail;
      document.head.appendChild(s);
    }

    var CDN1 = 'https://cdn.jsdelivr.net/npm/vexflow@4.2.2/build/cjs/vexflow.js';
    var CDN2 = 'https://unpkg.com/vexflow@4.2.2/build/cjs/vexflow.js';
    loadScript(CDN1, renderAll, function() {
      loadScript(CDN2, renderAll, renderAll);
    });
  })();
  </script>
</body>
</html>
```

---

## 打印优化

- 避免题目跨页断开（`page-break-inside: avoid`）
- 答题区域留足空白
- 分值标注在每题右侧括号内
- **打印宽度常量**：A4 可用宽度 = 210mm - 左边距20mm - 右边距15mm = 175mm ≈ **621px**（96dpi）

## 音乐符号使用原则

详见 `references/music-symbols.md`，核心原则：
- 优先使用 Unicode 音乐符号（U+1D100 起的音乐符号块）
- 降号用 ♭（U+266D），升号用 ♯（U+266F），还原号用 ♮（U+266E）
- 五线谱复杂内容用 VexFlow 绘制
- 所有符号需在主流浏览器打印预览中验证可见

## 题型模板

详见 `references/exam-structure.md`，包含各题型的标准格式和示例。
