From 617c59a1a13ee6fcdb79ff867d77d96751aaa6d6 Mon Sep 17 00:00:00 2001 From: alex xiang Date: Thu, 22 Oct 2026 22:18:10 +0800 Subject: [PATCH 01/31] fix: correct score notation, Richter mapping and import validation --- docs/stages/stage-4.md | 32 +++++++ src/App.tsx | 43 ++++++--- src/components/ScoreSheet.tsx | 163 +++++++++++++++++++++++----------- src/lib/export.ts | 8 +- src/lib/harmonica.ts | 47 ++++++---- src/lib/notation.ts | 24 +++++ src/lib/scoreImport.ts | 24 +++-- src/lib/store.ts | 5 +- src/styles.css | 2 + tests/builtinScores.test.ts | 19 ++++ tests/harmonica.test.ts | 52 +++++++++++ tests/scoreImport.test.ts | 21 +++++ tests/scoreRendering.test.ts | 34 +++++++ tools/build-scores.ts | 8 ++ 14 files changed, 391 insertions(+), 91 deletions(-) create mode 100644 docs/stages/stage-4.md create mode 100644 src/lib/notation.ts create mode 100644 tests/harmonica.test.ts create mode 100644 tests/scoreRendering.test.ts diff --git a/docs/stages/stage-4.md b/docs/stages/stage-4.md new file mode 100644 index 0000000..4f97c02 --- /dev/null +++ b/docs/stages/stage-4.md @@ -0,0 +1,32 @@ +# Stage 4 — notation correctness and validation + +Date: 2026-10-22 (Asia/Shanghai). Baseline: 9078226. + +## User-visible changes + +- Sustained notes display duration dashes; long rests display repeated zeroes. Multiple octave dots and ties crossing line breaks are retained. +- Dense rows wrap within the page; mobile notation reflows into readable short rows instead of shrinking the entire desktop sheet. +- Standard Richter keys G–B use their normal third-octave roots; C–F# use fourth-octave roots. C-harp 10 blow B6 is one semitone bent, Bb6 two. Blow bends are labeled as blowing. The engine also covers standard overdraw pitches when its advanced mode is explicitly enabled; the current UI remains in normal/bend mode. +- Imports reject zero meter, impossible dates, unrenderable note lengths, invalid octave ranges and malformed source formats. Invalid ties and beat mismatches are visible beside the score. A single incomplete measure no longer qualifies as a complementary pickup pair. +- Failed local storage writes retain the import dialog and JSON. Export errors are visible. Printed titles are assigned as text instead of interpolated into HTML. +- Score generation checks rhythm, ties, provenance fields and duplicate IDs before writing. A regression check compares all generated scores with their source DSL. + +## Evidence + +- `npm test`: 24/24 passed. +- `npm run scores:build` and `npm run scores:validate`: 14/14 passed; source melodies unchanged. +- `npm run build` and `git diff --check`: passed. +- Chromium interaction checks at 1440×1000 and 390×844, dark UI: search/select, import, invalid meter, quota failure, SVG content, PNG dimensions (2160 px wide), cross-row ties, multiple octaves and print-title injection passed. No page errors or console warnings/errors. +- Browser plugin was not available; regular Playwright used through the locally installed runtime. UI screenshots were inspected. Mobile screenshot capture waits for the drawer transition to finish. + +## References and limits + +- [HOHNER Standard Richter tables](https://www.hohner-cshop.de/out/media/HOHNER%20DATASHEET%20for%2010-hole%20Standard%20Richter.pdf), accessed 2026-10-22. +- [Steve Baker: Bending & Overblowing](https://my.hohner.de/t/harmonica-terminology-3-bending-overblowing/1377), accessed 2026-10-22. +- Source URLs/dates are provenance metadata, not proof of melody correctness. This stage did not freshly transcribe or independently recheck every historical melody. +- Standard Richter only; Low/High and alternate tunings are not modeled. Triplets remain unsupported. Physical printer pagination, real-device Safari/Firefox and listening/human performance verification remain outside this stage's evidence. +- Local stage completion; no remote release or ZIP created. Long-term archive: `C:\Users\ax2\Documents\技术博客\harpscore`. + +## Next + +Stage 5: expand the traceable library to at least 40 distinct scores, balancing Chinese familiar/introductory material with blues exercises and Western traditional tunes as requested on 2026-10-22. Original exercises and transposed copies are counted separately. Continue until the daily 02:00 Beijing pause boundary, then save handoff and pause the goal without automatic resumption. diff --git a/src/App.tsx b/src/App.tsx index cc3b4f7..90e6d51 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { } from './lib/store'; import { exportSvg, exportPng, exportPdf, exportJson } from './lib/export'; import ScoreSheet from './components/ScoreSheet'; +import { scoreFromObject } from './lib/scoreImport'; function ScoreItem({ s, active, onClick }: { s: Score; active: boolean; onClick: () => void }) { return ( @@ -26,6 +27,7 @@ function AnalysisPanel({ score }: { score: Score }) { const a = useMemo(() => analyzeScore(score, false), [score]); const warns = a.issues.filter((i) => i.severity === 'warning'); const errs = a.issues.filter((i) => i.severity === 'error'); + const notationWarnings = useMemo(() => scoreFromObject(score).warnings, [score]); const sourceFormat = score.sourceFormat === 'jianpu-text' ? '文字简谱' : score.sourceFormat === 'jianpu-image' @@ -42,6 +44,9 @@ function AnalysisPanel({ score }: { score: Score }) { {errs.length === 0 ? · 可完整演奏 : · {errs.length} 个音超出能力范围} + {notationWarnings.length > 0 &&
记谱提示:{notationWarnings.join(';')}
} + {errs.length > 0 &&
{errs.map((e, i) => + 第{e.lineIndex + 1}行第{e.measureIndex + 1}小节第{e.noteIndex + 1}音:{e.reason};)}
} {score.notes &&
演奏提示:{score.notes}
} {score.source && (
@@ -56,7 +61,7 @@ function AnalysisPanel({ score }: { score: Score }) { {warns.length > 0 && warns.length <= 12 && (
{warns.map((w, i) => ( - 第{w.measureIndex + 1}小节 {w.reason}; + 第{w.lineIndex + 1}行第{w.measureIndex + 1}小节 {w.reason}; ))}
)} @@ -74,6 +79,13 @@ export default function App() { const [importError, setImportError] = useState(''); const [notice, setNotice] = useState(''); const fileRef = useRef(null); + const [actionError, setActionError] = useState(''); + const runAction = async (action: () => void | Promise) => { + setActionError(''); + try { await action(); } catch (error) { + setActionError(error instanceof Error ? error.message : '操作失败,请重试'); + } + }; const scores = useMemo(() => { const all = allScores(lib); @@ -95,14 +107,18 @@ export default function App() { return; } const score = uniqueScoreId(parsedScore, allScores(lib).map((item) => item.id)); - const newLib = saveUserScore(lib, score); + let newLib: Library; + try { newLib = saveUserScore(lib, score); } catch { + setImportError('未保存:浏览器存储不可用或空间不足。请保留曲谱 JSON,释放空间后重试。'); + return; + } setLib(newLib); setCurrentId(score.id); setImportOpen(false); setImportText(''); setImportError(''); setNotice(warnings.length - ? `已导入《${score.title}》,另有 ${warnings.length} 个节拍提示` + ? `已导入《${score.title}》,另有 ${warnings.length} 个记谱提示,详见谱面下方` : `已导入《${score.title}》`); }; @@ -141,27 +157,32 @@ export default function App() { onChange={async (e) => { const file = e.target.files?.[0]; if (file) { - setImportText(await file.text()); - setImportError(''); + try { + setImportText(await file.text()); + setImportError(''); + } catch { setImportError('无法读取文件,请重新选择或粘贴 JSON'); } } e.target.value = ''; }} /> {notice &&
{notice}
} + {actionError &&
{actionError}
} {current ? ( <>
- - - - + + + +
{!current.builtin && ( )} diff --git a/src/components/ScoreSheet.tsx b/src/components/ScoreSheet.tsx index 1a954c8..c67a654 100644 --- a/src/components/ScoreSheet.tsx +++ b/src/components/ScoreSheet.tsx @@ -4,28 +4,29 @@ * 下行:口琴演奏标记(吹吸 + 孔号 + 压音符号 + 技巧) * 小节线 / 反复记号 / 双纵线贯穿两行 */ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import type { Measure, Note, Score } from '../types/score'; +import { noteDuration } from '../lib/notation'; /* ---------- 排版常量 ---------- */ export const LAYOUT = { pageWidth: 1080, padX: 56, padTop: 40, - headerHeight: 150, + headerHeight: 170, lineGap: 46, // 行内纵向位置(相对行顶) highDotY: 8, numberY: 34, - lowDotY: 44, - beam1Y: 54, - beam2Y: 60, - tabY: 88, - lyricY: 112, - lineBaseHeight: 100, // 不含歌词 + lowDotY: 43, + beam1Y: 65, + beam2Y: 71, + tabY: 98, + lyricY: 122, + lineBaseHeight: 110, // 不含歌词 lyricHeight: 22, barTop: 10, - barBottom: 92, + barBottom: 102, }; const INK = '#1b1b1b'; @@ -59,24 +60,48 @@ interface PlacedLine { } function noteWidth(n: Note): number { - const dur = Math.min(n.beats * (n.dotted ? 1.5 : 1), 4); + const dur = noteDuration(n); + if (dur >= 2) return 38 * dur; return Math.max(38, 30 + dur * 16); } +function symbolCount(n: Note): number { + return noteDuration(n) >= 2 ? noteDuration(n) : 1; +} + function beamLevel(n: Note): number { if (n.beats <= 0.25) return 2; if (n.beats <= 0.5) return 1; return 0; } -export function layoutScore(score: Score): { lines: PlacedLine[]; width: number; height: number } { - const W = LAYOUT.pageWidth; +export function layoutScore(score: Score, pageWidth = LAYOUT.pageWidth): { lines: PlacedLine[]; width: number; height: number } { + const W = pageWidth; + const compact = W < 700; const avail = W - LAYOUT.padX * 2; const lines: PlacedLine[] = []; - let y = LAYOUT.padTop + LAYOUT.headerHeight; + let y = LAYOUT.padTop + LAYOUT.headerHeight + (compact ? 28 : 0); - score.lines.forEach((rawLine, li) => { - const firstLineIndent = li === 0 ? 64 : 0; + // Preserve authored line breaks but wrap dense rows instead of clipping the SVG. + const rows: Measure[][] = []; + score.lines.forEach((rawLine) => { + let row: Measure[] = []; + let rowWidth = 0; + rawLine.forEach((measure) => { + const width = measure.notes.reduce((sum, n) => sum + noteWidth(n), 0) + 16; + const target = avail - (rows.length === 0 ? (compact ? 0 : 64) : 0); + if (row.length && rowWidth + width > target / 0.85) { + rows.push(row); + row = []; + rowWidth = 0; + } + row.push(measure); + rowWidth += width; + }); + if (row.length) rows.push(row); + }); + rows.forEach((rawLine, li) => { + const firstLineIndent = li === 0 && !compact ? 64 : 0; // 自然宽度 const mWidths = rawLine.map((m) => { const nw = m.notes.reduce((s, n) => s + noteWidth(n), 0); @@ -84,7 +109,7 @@ export function layoutScore(score: Score): { lines: PlacedLine[]; width: number; }); const natural = mWidths.reduce((a, b) => a + b, 0); const target = avail - firstLineIndent; - const scale = Math.min(Math.max(target / natural, 0.55), 1.9); + const scale = Math.min(target / natural, 1.9); let x = LAYOUT.padX + firstLineIndent; const measures: PlacedMeasure[] = rawLine.map((m, mi) => { @@ -92,7 +117,7 @@ export function layoutScore(score: Score): { lines: PlacedLine[]; width: number; let nx = x + 8 * scale; const notes: PlacedNote[] = m.notes.map((n) => { const w = noteWidth(n) * scale; - const cx = nx + w / 2; + const cx = nx + w / (2 * symbolCount(n)); nx += w; return { note: n, x: cx, w }; }); @@ -107,7 +132,7 @@ export function layoutScore(score: Score): { lines: PlacedLine[]; width: number; y += height + LAYOUT.lineGap; }); - return { lines, width: W, height: y - LAYOUT.lineGap + LAYOUT.padTop }; + return { lines, width: W, height: y - LAYOUT.lineGap + LAYOUT.padTop + (compact ? 46 : 0) }; } /* ---------- 渲染 ---------- */ @@ -179,7 +204,9 @@ function NoteGlyph({ p, y }: { p: PlacedNote; y: number }) { // 高八度点 if ((n.octave ?? 0) > 0 && n.degree !== 0) { - els.push(); + for (let i = 0; i < n.octave!; i++) { + els.push(); + } } // 简谱数字 els.push( @@ -187,12 +214,20 @@ function NoteGlyph({ p, y }: { p: PlacedNote; y: number }) { fontFamily={NUM_FONT} fontSize={n.degree === 0 ? 19 : 21} fontWeight={600} fill={INK}> {acc && {acc}} {n.degree} - {n.dotted && .} + {n.dotted && noteDuration(n) < 2 && .} ); // 低音点 if ((n.octave ?? 0) < 0 && n.degree !== 0) { - els.push(); + for (let i = 0; i < -n.octave!; i++) { + els.push(); + } + } + // In numbered notation, sustained notes use dashes; long rests repeat the zero. + for (let i = 1; i < symbolCount(n); i++) { + els.push({n.degree === 0 ? '0' : '−'}); } // 口琴孔位标记:箭头 + 孔号(吹气↑ / 吸气↓) if (n.hole != null && n.breath) { @@ -242,28 +277,32 @@ function Beams({ m, y }: { m: PlacedMeasure; y: number }) { return {els}; } -/** 延音线(弧线,仅在同一行内绘制) */ -function Ties({ line }: { line: PlacedLine }) { +/** At a line break, draw both the outgoing and incoming halves of a tie. */ +function Ties({ line, previous }: { line: PlacedLine; previous?: PlacedLine }) { const els: React.ReactNode[] = []; const flat: PlacedNote[] = []; line.measures.forEach((m) => m.notes.forEach((n) => flat.push(n))); + const arc = (key: string, x1: number, x2: number) => { + const yy = line.y - 13; + const h = 7 + Math.min((x2 - x1) / 18, 6); + els.push(); + }; + const previousMeasure = previous?.measures[previous.measures.length - 1]; + const previousNote = previousMeasure?.notes[previousMeasure.notes.length - 1]?.note; + if (previousNote?.tie) arc('incoming', line.measures[0].x, flat[0].x - 8); flat.forEach((p, i) => { - if (!p.note.tie || i + 1 >= flat.length) return; + if (!p.note.tie) return; const q = flat[i + 1]; - const x1 = p.x + p.w * 0.22; - const x2 = q.x - q.w * 0.22; - const yy = line.y + LAYOUT.numberY - 24; - const h = 7 + Math.min((x2 - x1) / 18, 6); - els.push( - - ); + const last = line.measures[line.measures.length - 1]; + arc(`t${i}`, p.x + 8, q ? q.x - 8 : last.x + last.w); }); return {els}; } -function Header({ score }: { score: Score }) { - const W = LAYOUT.pageWidth; +function Header({ score, width }: { score: Score; width: number }) { + const W = width; + const compact = W < 700; const stars = '★'.repeat(score.difficulty) + '☆'.repeat(5 - score.difficulty); // 把位只保留中文,去掉括号里的英文(避免信息条溢出) const pos = (score.position ?? '').replace(/[((][^))]*[))]/g, '').trim(); @@ -274,10 +313,10 @@ function Header({ score }: { score: Score }) { return ( {score.title} + fontSize={compact ? 24 : 30} fontWeight={700} fill={INK}>{score.title} {score.subtitle && ( {score.subtitle} + fontSize={compact ? 11 : 14} fill={FAINT}>{score.subtitle} )} {/* 左侧:调号/拍号/速度 */} @@ -285,28 +324,33 @@ function Header({ score }: { score: Score }) { {/* 右侧:作曲/来源 */} {score.composer && ( - {score.composer} )} {/* 口琴信息条:宽度随文本自适应 */} - + {infoText} - ); } /** 图例(首页底部):吹吸颜色说明 */ -function Legend({ y }: { y: number }) { - const W = LAYOUT.pageWidth; +function Legend({ y, width }: { y: number; width: number }) { + const W = width; const x0 = LAYOUT.padX; const ty = y - 3.5; // 箭头基线,与文字视觉对齐 + if (width < 700) return + ↑ 吹气 ↓ 吸气 ' 每符压低半音 + ° 超吹/超吸 ~ 颤音 + HarpScore + ; return ( 图例: @@ -318,9 +362,9 @@ function Legend({ y }: { y: number }) { ' 压音(每符半音) - ° 超吹 + ° 超吹/吸 - + ~ 颤音 HarpScore @@ -328,22 +372,20 @@ function Legend({ y }: { y: number }) { ); } -export function ScoreSheetSvg({ score, standalone = false }: { score: Score; standalone?: boolean }) { - const { lines, width, height } = layoutScore(score); +export function ScoreSheetSvg({ score, standalone = false, pageWidth = LAYOUT.pageWidth }: { score: Score; standalone?: boolean; pageWidth?: number }) { + const { lines, width, height } = layoutScore(score, pageWidth); const L = LAYOUT; return ( -
+
{lines.map((line, li) => ( {/* 首行谱号位置:调号拍号(简谱惯例已在头部标明,行首画粗细起线) */} - - {line.measures[0].measure.repeatBegin && ( - - )} + {line.measures.map((m, mi) => { const isLast = mi === line.measures.length - 1; const isLastLine = li === lines.length - 1; @@ -352,24 +394,37 @@ export function ScoreSheetSvg({ score, standalone = false }: { score: Score; sta : m.measure.sectionEnd ? 'final' : 'normal'; return ( + {mi > 0 && m.measure.repeatBegin && } {m.notes.map((p, ni) => )} ); })} - + ))} - +
); } export default function ScoreSheet({ score }: { score: Score }) { + const paper = useRef(null); + const [pageWidth, setPageWidth] = useState(LAYOUT.pageWidth); + useEffect(() => { + const element = paper.current; + if (!element) return; + const observer = new ResizeObserver(([entry]) => { + const width = entry.contentRect.width; + setPageWidth(width < 700 ? Math.max(420, Math.round(width)) : LAYOUT.pageWidth); + }); + observer.observe(element); + return () => observer.disconnect(); + }, []); return ( -
- +
+
); } diff --git a/src/lib/export.ts b/src/lib/export.ts index 9338509..da246b3 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -58,7 +58,8 @@ export async function exportPng(score: Score, scale = 2): Promise { ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); - if (blob) download(blob, `${safeName(score.title)}.png`); + if (!blob) throw new Error('PNG 编码失败,请尝试 SVG 导出'); + download(blob, `${safeName(score.title)}.png`); } finally { URL.revokeObjectURL(url); } @@ -68,11 +69,12 @@ export async function exportPng(score: Score, scale = 2): Promise { export function exportPdf(score: Score) { const str = renderToSvgString(score); const win = window.open('', '_blank'); - if (!win) return; - win.document.write(`${score.title} - HarpScore + if (!win) throw new Error('打印窗口被浏览器拦截,请允许弹出窗口后重试'); + win.document.write(`HarpScore ${str}