diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css
index ec8ef36..6c2ce12 100644
--- a/apps/web/src/styles/global.css
+++ b/apps/web/src/styles/global.css
@@ -3051,6 +3051,954 @@ input[type="range"] {
width: 92px;
}
+/* AI Chart Engine */
+.workspace-header-actions,
+.chart-engine-header-left,
+.chart-engine-header nav,
+.chart-generation-header nav,
+.project-card-actions {
+ display: flex;
+ align-items: center;
+}
+
+.workspace-header-actions {
+ margin-left: auto;
+ gap: 8px;
+}
+
+.chart-engine-link,
+.chart-workspace-button,
+.chart-card-link {
+ color: var(--accent-strong);
+}
+
+.project-card-actions {
+ margin-left: auto;
+ gap: 10px;
+}
+
+.project-card-actions .text-button {
+ margin-left: 0;
+}
+
+.chart-engine-shell,
+.chart-generation-shell {
+ display: flex;
+ height: 100dvh;
+ min-height: 0;
+ overflow: hidden;
+ flex-direction: column;
+ background: var(--bg-primary);
+}
+
+.chart-engine-header,
+.chart-generation-header {
+ display: flex;
+ min-height: 76px;
+ padding: 0 18px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-panel);
+}
+
+.chart-engine-header-left {
+ min-width: 0;
+ gap: 13px;
+}
+
+.chart-engine-header-left > div:last-child {
+ min-width: 0;
+}
+
+.chart-engine-header h1,
+.chart-generation-header h1,
+.chart-panel-heading h2,
+.reference-chart-header h2,
+.generated-chart-heading h2 {
+ margin: 0;
+}
+
+.chart-engine-header h1,
+.chart-generation-header h1 {
+ margin-top: 3px;
+ font-size: 17px;
+}
+
+.chart-generation-header p,
+.reference-chart-header p,
+.chart-panel-heading p {
+ margin: 4px 0 0;
+ color: var(--text-secondary);
+}
+
+.chart-generation-header p {
+ overflow: hidden;
+ max-width: 460px;
+ font-size: 9px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.chart-engine-header nav,
+.chart-generation-header nav {
+ min-width: max-content;
+ gap: 8px;
+}
+
+.real-data-badge {
+ padding: 7px 9px;
+ border: 1px solid color-mix(in srgb, var(--success) 38%, var(--border));
+ border-radius: var(--radius-sm);
+ background: var(--success-soft);
+ color: var(--success);
+ font-size: 9px;
+}
+
+.chart-corpus-strip,
+.chart-song-strip {
+ display: grid;
+ min-height: 60px;
+ flex: 0 0 60px;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-subtle);
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+}
+
+.chart-corpus-strip > div,
+.chart-song-strip > div {
+ display: flex;
+ min-width: 0;
+ padding: 10px 16px;
+ justify-content: center;
+ flex-direction: column;
+ border-right: 1px solid var(--border);
+}
+
+.chart-corpus-strip > div:last-child,
+.chart-song-strip > div:last-child {
+ border-right: 0;
+}
+
+.chart-corpus-strip small,
+.chart-song-strip small,
+.reference-chart-facts small,
+.validator-metrics small,
+.generation-corpus-result small {
+ color: var(--text-tertiary);
+ font-size: 7px;
+ letter-spacing: .08em;
+}
+
+.chart-corpus-strip strong,
+.chart-song-strip strong {
+ margin-top: 4px;
+ overflow: hidden;
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.chart-library-layout {
+ display: grid;
+ min-height: 0;
+ flex: 1 1 auto;
+ grid-template-columns: 336px minmax(0, 1fr);
+}
+
+.reference-library-panel {
+ display: flex;
+ min-height: 0;
+ padding: 16px 0 0;
+ flex-direction: column;
+ border-right: 1px solid var(--border);
+ background: var(--bg-panel);
+}
+
+.chart-panel-heading {
+ padding: 0 16px 13px;
+}
+
+.chart-panel-heading h2 {
+ margin-top: 4px;
+ font-size: 15px;
+}
+
+.chart-panel-heading p {
+ font-size: 9px;
+ line-height: 1.5;
+}
+
+.chart-library-search {
+ display: flex;
+ height: 34px;
+ margin: 0 12px;
+ padding: 0 9px;
+ align-items: center;
+ gap: 7px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-primary);
+ color: var(--text-tertiary);
+}
+
+.chart-library-search:focus-within {
+ border-color: var(--border-focus);
+ box-shadow: var(--focus-ring);
+}
+
+.chart-library-search input {
+ min-width: 0;
+ flex: 1;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--text-primary);
+ font-size: 10px;
+}
+
+.chart-group-tabs {
+ display: flex;
+ margin: 9px 12px 0;
+ gap: 3px;
+}
+
+.chart-group-tabs button {
+ height: 27px;
+ padding: 0 8px;
+ flex: 1;
+ border-color: transparent;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 8px;
+}
+
+.chart-group-tabs button.active {
+ border-color: var(--border-strong);
+ background: var(--bg-muted);
+ color: var(--text-primary);
+}
+
+.reference-chart-count {
+ padding: 9px 14px 7px;
+ color: var(--text-tertiary);
+ font-size: 8px;
+}
+
+.reference-chart-count strong {
+ color: var(--text-secondary);
+}
+
+.reference-chart-list {
+ min-height: 0;
+ padding-bottom: 12px;
+ overflow-y: auto;
+ flex: 1;
+ border-top: 1px solid var(--border);
+}
+
+.reference-chart-item {
+ display: flex;
+ width: 100%;
+ min-height: 76px;
+ padding: 10px 13px;
+ align-items: stretch;
+ flex-direction: column;
+ gap: 4px;
+ text-align: left;
+ border: 0;
+ border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
+ border-radius: 0;
+ background: transparent;
+}
+
+.reference-chart-item:hover {
+ background: var(--bg-hover);
+}
+
+.reference-chart-item.active {
+ background: var(--accent-soft);
+ box-shadow: inset 3px 0 var(--accent);
+}
+
+.reference-chart-item > span {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.reference-chart-item small {
+ color: var(--text-tertiary);
+ font-size: 7px;
+ letter-spacing: .08em;
+}
+
+.reference-chart-item b {
+ color: var(--accent-strong);
+ font-size: 9px;
+}
+
+.reference-chart-item strong {
+ overflow: hidden;
+ color: var(--text-primary);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.reference-chart-item p {
+ margin: 0;
+ color: var(--text-secondary);
+ font-size: 8px;
+}
+
+.reference-list-state {
+ padding: 26px 16px;
+ text-align: center;
+ color: var(--text-secondary);
+ font-size: 9px;
+}
+
+.reference-list-state p {
+ margin: 6px 0;
+}
+
+.reference-preview-workspace {
+ display: grid;
+ min-width: 0;
+ min-height: 0;
+ padding: 18px;
+ overflow: hidden;
+ background: var(--bg-primary);
+ grid-template-rows: auto auto minmax(0, 1fr);
+}
+
+.reference-chart-header,
+.generated-chart-heading {
+ display: flex;
+ max-width: 880px;
+ margin: 0 auto;
+ padding: 0 0 12px;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.reference-chart-header h2,
+.generated-chart-heading h2 {
+ margin-top: 4px;
+ font-size: 18px;
+}
+
+.reference-chart-header p {
+ font-size: 9px;
+}
+
+.reference-chart-meter {
+ display: flex;
+ min-width: 68px;
+ padding-left: 14px;
+ align-items: flex-end;
+ flex-direction: column;
+ border-left: 1px solid var(--border);
+}
+
+.reference-chart-meter small {
+ color: var(--text-tertiary);
+ font-size: 8px;
+}
+
+.reference-chart-meter strong {
+ margin-top: 3px;
+ color: var(--accent-strong);
+ font-size: 20px;
+}
+
+.reference-chart-facts {
+ display: grid;
+ max-width: 880px;
+ margin: 0 auto 12px;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--bg-panel);
+}
+
+.reference-chart-facts > span {
+ display: flex;
+ min-width: 0;
+ padding: 9px 10px;
+ flex-direction: column;
+ border-right: 1px solid var(--border);
+}
+
+.reference-chart-facts > span:last-child {
+ border-right: 0;
+}
+
+.reference-chart-facts strong {
+ margin-top: 3px;
+ overflow: hidden;
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.chart-full-state {
+ display: grid;
+ min-height: 360px;
+ padding: 28px;
+ place-content: center;
+ gap: 9px;
+ text-align: center;
+ color: var(--text-secondary);
+}
+
+.chart-full-state p {
+ max-width: 460px;
+ margin: 0;
+ font-size: 10px;
+ line-height: 1.5;
+}
+
+.chart-player {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ max-width: 880px;
+ margin: 0 auto;
+ overflow: hidden;
+ flex-direction: column;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--bg-panel);
+ box-shadow: var(--shadow-soft);
+ container-type: inline-size;
+}
+
+.five-lane-preview {
+ width: 100%;
+ min-height: 0;
+ overflow: hidden;
+ flex: 1 1 auto;
+ background: var(--chart-canvas-bg);
+}
+
+.five-lane-preview canvas {
+ display: block;
+ max-width: 100%;
+}
+
+.chart-audio-error {
+ margin: 8px;
+}
+
+.chart-transport {
+ display: flex;
+ min-height: 62px;
+ padding: 9px 12px;
+ align-items: center;
+ gap: 9px;
+ border-top: 1px solid var(--border);
+ background: var(--bg-panel);
+}
+
+.chart-transport .transport-secondary {
+ flex: 0 0 34px;
+}
+
+.chart-transport .play-button {
+ flex: 0 0 40px;
+}
+
+.chart-transport-time {
+ display: flex;
+ min-width: 124px;
+ align-items: baseline;
+ gap: 5px;
+ font-variant-numeric: tabular-nums;
+}
+
+.chart-transport-time strong {
+ font-size: 13px;
+}
+
+.chart-transport-time span {
+ color: var(--text-tertiary);
+ font-size: 9px;
+}
+
+.chart-scroll-speed-control {
+ display: flex;
+ height: 34px;
+ padding-left: 8px;
+ align-items: center;
+ flex: 0 0 auto;
+ gap: 6px;
+ border-left: 1px solid var(--border);
+ color: var(--text-tertiary);
+ font-size: 8px;
+}
+
+.chart-scroll-speed-control select {
+ height: 30px;
+ min-width: 62px;
+ padding: 0 7px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-sm);
+ outline: 0;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 10px;
+ font-weight: 650;
+}
+
+.chart-scroll-speed-control select:focus {
+ border-color: var(--border-focus);
+ box-shadow: var(--focus-ring);
+}
+
+.chart-seek-control {
+ display: flex;
+ min-width: 120px;
+ flex: 1;
+}
+
+.chart-seek-control input {
+ width: 100%;
+ accent-color: var(--accent);
+}
+
+@container (max-width: 540px) {
+ .chart-transport {
+ flex-wrap: wrap;
+ gap: 7px 6px;
+ }
+
+ .chart-transport-time {
+ min-width: 0;
+ flex: 1 1 80px;
+ }
+
+ .chart-seek-control {
+ min-width: 100%;
+ order: 2;
+ flex-basis: 100%;
+ }
+}
+
+@container (max-width: 380px) {
+ .chart-scroll-speed-control > span,
+ .chart-transport-time > span {
+ display: none;
+ }
+}
+
+.chart-generation-header {
+ flex: 0 0 76px;
+}
+
+.chart-generation-layout {
+ display: grid;
+ min-height: 0;
+ flex: 1 1 auto;
+ grid-template-columns: 282px minmax(390px, 1fr) 300px;
+}
+
+.chart-generation-controls,
+.chart-validation-panel {
+ min-height: 0;
+ padding-top: 17px;
+ overflow-y: auto;
+ background: var(--bg-panel);
+}
+
+.chart-generation-controls {
+ border-right: 1px solid var(--border);
+}
+
+.chart-validation-panel {
+ border-left: 1px solid var(--border);
+}
+
+.validator-footwork {
+ display: flex;
+ margin: 10px 16px 0;
+ padding: 10px 11px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-primary);
+}
+
+.validator-footwork > span {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.validator-footwork small {
+ color: var(--text-tertiary);
+ font-size: 7px;
+ letter-spacing: .08em;
+}
+
+.validator-footwork b,
+.validator-footwork strong {
+ font-size: 9px;
+}
+
+.validator-footwork.valid {
+ border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
+}
+
+.validator-footwork.valid strong {
+ color: var(--accent);
+}
+
+.validator-footwork.invalid {
+ border-color: color-mix(in srgb, var(--danger) 55%, var(--border));
+}
+
+.validator-footwork.invalid strong {
+ color: var(--danger);
+}
+
+.difficulty-control {
+ display: flex;
+ padding: 15px 16px;
+ flex-direction: column;
+ gap: 9px;
+ border-top: 1px solid var(--border);
+ border-bottom: 1px solid var(--border);
+}
+
+.difficulty-control > span {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.difficulty-control strong,
+.spin-control strong {
+ font-size: 10px;
+}
+
+.difficulty-control > span small {
+ color: var(--text-tertiary);
+ font-size: 7px;
+ letter-spacing: .08em;
+}
+
+.difficulty-control select {
+ width: 100%;
+ height: 36px;
+ padding: 0 10px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-sm);
+ outline: 0;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 11px;
+ font-weight: 650;
+}
+
+.difficulty-control select:focus {
+ border-color: var(--border-focus);
+ box-shadow: var(--focus-ring);
+}
+
+.spin-control {
+ display: grid;
+ padding: 15px 16px;
+ align-items: center;
+ gap: 9px;
+ grid-template-columns: minmax(0, 1fr) auto 28px;
+ border-bottom: 1px solid var(--border);
+}
+
+.spin-control > span {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.spin-control small {
+ color: var(--text-tertiary);
+ font-size: 8px;
+}
+
+.spin-control b {
+ color: var(--text-secondary);
+ font-size: 8px;
+}
+
+.generation-source-note,
+.generation-requirement,
+.generation-corpus-result {
+ margin: 13px 15px 0;
+ padding: 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-subtle);
+ color: var(--text-secondary);
+ font-size: 8px;
+ line-height: 1.5;
+}
+
+.generation-source-note i {
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ margin-right: 5px;
+ border-radius: 50%;
+ background: var(--success);
+}
+
+.generation-requirement {
+ border-color: color-mix(in srgb, var(--warning) 35%, var(--border));
+ background: var(--warning-soft);
+ color: var(--warning);
+}
+
+.chart-generation-controls > .error-banner {
+ margin: 13px 15px 0;
+ font-size: 9px;
+}
+
+.generate-chart-button {
+ display: flex;
+ width: calc(100% - 30px);
+ min-height: 39px;
+ margin: 14px 15px 0;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+}
+
+.generation-corpus-result {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.generation-corpus-result strong {
+ color: var(--text-primary);
+ font-size: 9px;
+}
+
+.generation-model-result {
+ margin-top: 4px;
+ padding-top: 5px;
+ border-top: 1px solid var(--border);
+ color: var(--accent-strong);
+}
+
+.generated-chart-preview {
+ display: grid;
+ min-width: 0;
+ min-height: 0;
+ padding: 17px;
+ overflow: hidden;
+ background: var(--bg-primary);
+ grid-template-rows: auto minmax(0, 1fr);
+}
+
+.generated-chart-heading {
+ max-width: 760px;
+}
+
+.generated-chart-heading > strong {
+ color: var(--accent-strong);
+ font-size: 18px;
+}
+
+.generated-chart-heading p {
+ margin: 4px 0 0;
+ color: var(--text-tertiary);
+ font-size: 8px;
+}
+
+.generated-chart-preview .chart-player {
+ max-width: 760px;
+}
+
+.reference-preview-workspace > .chart-full-state,
+.generated-chart-preview > .chart-full-state {
+ min-height: 0;
+ grid-row: 1 / -1;
+}
+
+.chart-empty-preview {
+ height: 100%;
+ min-height: 430px;
+}
+
+.empty-preview-lanes {
+ display: grid;
+ width: 220px;
+ height: 128px;
+ margin: 0 auto 12px;
+ grid-template-columns: repeat(5, 1fr);
+ border: 1px solid var(--border);
+ background: var(--chart-canvas-bg);
+}
+
+.empty-preview-lanes i {
+ border-right: 1px solid var(--chart-lane-divider);
+}
+
+.empty-preview-lanes i:last-child {
+ border-right: 0;
+}
+
+.validator-score {
+ display: flex;
+ margin: 0 15px;
+ padding: 13px;
+ align-items: center;
+ gap: 11px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--bg-subtle);
+}
+
+.validator-score > strong {
+ font-size: 30px;
+ font-variant-numeric: tabular-nums;
+}
+
+.validator-score.valid > strong,
+.validator-score.valid b {
+ color: var(--success);
+}
+
+.validator-score.invalid > strong,
+.validator-score.invalid b {
+ color: var(--danger);
+}
+
+.validator-score > span {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.validator-score b {
+ font-size: 10px;
+}
+
+.validator-score small {
+ color: var(--text-tertiary);
+ font-size: 8px;
+}
+
+.validator-metrics {
+ display: grid;
+ margin: 12px 15px 0;
+ grid-template-columns: 1fr 1fr;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+}
+
+.validator-metrics > span {
+ display: flex;
+ padding: 9px;
+ flex-direction: column;
+ border-right: 1px solid var(--border);
+ border-bottom: 1px solid var(--border);
+}
+
+.validator-metrics > span:nth-child(2n) {
+ border-right: 0;
+}
+
+.validator-metrics > span:nth-last-child(-n + 2) {
+ border-bottom: 0;
+}
+
+.validator-metrics strong {
+ margin-top: 3px;
+ font-size: 11px;
+}
+
+.validator-issues {
+ display: flex;
+ margin: 12px 15px 16px;
+ padding: 0;
+ flex-direction: column;
+ gap: 7px;
+ list-style: none;
+}
+
+.validator-issues li {
+ padding: 9px;
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--warning);
+ border-radius: var(--radius-sm);
+ background: var(--bg-subtle);
+}
+
+.validator-issues li.severity-error {
+ border-left-color: var(--danger);
+}
+
+.validator-issues li.severity-info {
+ border-left-color: var(--accent);
+}
+
+.validator-issues li > span {
+ color: var(--text-tertiary);
+ font-size: 7px;
+ text-transform: uppercase;
+}
+
+.validator-issues li strong {
+ display: block;
+ margin-top: 3px;
+ font-size: 8px;
+}
+
+.validator-issues li p {
+ margin: 4px 0;
+ color: var(--text-secondary);
+ font-size: 8px;
+ line-height: 1.4;
+}
+
+.validator-issues li small {
+ color: var(--text-tertiary);
+ font-size: 7px;
+}
+
+.validator-clean,
+.validator-pending {
+ margin: 12px 15px;
+ padding: 13px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-secondary);
+ font-size: 9px;
+ line-height: 1.5;
+}
+
+.validator-clean {
+ border-color: color-mix(in srgb, var(--success) 35%, var(--border));
+ background: var(--success-soft);
+ color: var(--success);
+}
+
+.chart-sm-export {
+ min-height: 32px;
+ padding: 0 13px;
+ font-size: 10px;
+}
+
/* Responsive */
@media (max-width: 1180px) {
.editor-topline {
@@ -3075,6 +4023,10 @@ input[type="range"] {
.alignment-workspace {
grid-template-columns: minmax(0, 1fr) 290px;
}
+
+ .chart-generation-layout {
+ grid-template-columns: 250px minmax(360px, 1fr) 270px;
+ }
}
@media (max-width: 900px) {
@@ -3138,6 +4090,36 @@ input[type="range"] {
.editor-guide-steps {
grid-template-columns: 1fr 1fr;
}
+
+ .chart-engine-shell,
+ .chart-generation-shell {
+ height: auto;
+ min-height: 100vh;
+ overflow: visible;
+ }
+
+ .chart-library-layout,
+ .chart-generation-layout {
+ display: block;
+ }
+
+ .reference-library-panel,
+ .chart-generation-controls,
+ .chart-validation-panel {
+ max-height: none;
+ overflow: visible;
+ border-right: 0;
+ border-left: 0;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .reference-chart-list {
+ max-height: 360px;
+ }
+
+ .chart-validation-panel {
+ padding-bottom: 14px;
+ }
}
@media (max-width: 640px) {
@@ -3192,6 +4174,50 @@ input[type="range"] {
.volume-control {
display: none;
}
+
+ .chart-engine-header,
+ .chart-generation-header {
+ min-height: 68px;
+ padding: 8px 11px;
+ }
+
+ .chart-engine-header .brand-copy,
+ .chart-generation-header .brand-copy,
+ .chart-engine-header .header-divider,
+ .chart-generation-header .back-link {
+ display: none;
+ }
+
+ .real-data-badge,
+ .chart-engine-header nav .toolbar-button,
+ .chart-generation-header nav .toolbar-button {
+ display: none;
+ }
+
+ .chart-corpus-strip,
+ .chart-song-strip {
+ overflow-x: auto;
+ grid-template-columns: repeat(5, minmax(118px, 1fr));
+ }
+
+ .reference-preview-workspace,
+ .generated-chart-preview {
+ padding: 12px;
+ }
+
+ .reference-chart-facts {
+ overflow-x: auto;
+ grid-template-columns: repeat(6, minmax(100px, 1fr));
+ }
+
+ .chart-transport {
+ flex-wrap: wrap;
+ }
+
+ .chart-seek-control {
+ min-width: 100%;
+ order: 2;
+ }
}
@media (prefers-reduced-motion: reduce) {
diff --git a/apps/web/src/styles/tokens.css b/apps/web/src/styles/tokens.css
index 842bbae..3c42c7b 100644
--- a/apps/web/src/styles/tokens.css
+++ b/apps/web/src/styles/tokens.css
@@ -72,4 +72,17 @@
--canvas-minimap-waveform: #4FA89A73;
--canvas-minimap-viewport: #A3ADAE14;
--canvas-minimap-viewport-border: #A3ADAE52;
+
+ --chart-canvas-bg: #070C0E;
+ --chart-lane: #0A1114;
+ --chart-lane-alternate: #0D1518;
+ --chart-lane-divider: #253138;
+ --chart-receptor: #A7C8C1;
+ --chart-judgment: #D26B76;
+ --chart-hold: #4FA89A99;
+ --chart-note-left-down: #628FBA;
+ --chart-note-left-up: #9180A9;
+ --chart-note-center: #D1B75F;
+ --chart-note-right-up: #BF7188;
+ --chart-note-right-down: #C18753;
}
diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts
index 82f97de..707bd84 100644
--- a/apps/web/src/types/index.ts
+++ b/apps/web/src/types/index.ts
@@ -405,3 +405,179 @@ export interface HitDisplayFilters {
}
export type GridSubdivision = '1/4' | '1/8' | '1/12' | '1/16' | '1/24' | '1/32';
+
+export type ChartMode = 'pump-single' | 'pump-double';
+export type ChartNoteType = 'tap' | 'hold' | 'mine';
+export type ChartIssueSeverity = 'info' | 'warning' | 'error';
+export type ReferenceChartGroup = 'SPEED_CLUB' | 'SPEED_DEVIL' | 'SPEED_REMIX';
+
+export interface ChartTempoPoint {
+ beat: number;
+ bpm: number;
+ timeSec: number;
+}
+
+export interface ChartNote {
+ lane: number;
+ type: ChartNoteType;
+ endTimeSec: number | null;
+ endBeat: number | null;
+ source: string;
+ confidence: number;
+ foot: 'left' | 'right' | null;
+}
+
+export interface ChartEvent {
+ timeSec: number;
+ beat: number;
+ measure: number;
+ subdivision: number;
+ rowIndex: number | null;
+ notes: ChartNote[];
+ sourceEventId: string | null;
+ sourceEventIds?: string[];
+ sourceHitPointIds?: string[];
+ anchorPriority?: 0 | 1 | 2;
+ pattern: string | null;
+}
+
+export interface ChartStatistics {
+ noteCount: number;
+ eventCount: number;
+ holdCount: number;
+ jumpCount: number;
+ mineCount: number;
+ durationSec: number;
+ npsAverage: number;
+ npsPeak: number;
+ singleRatio: number;
+ jumpRatio: number;
+ holdRatio: number;
+ laneCounts: number[];
+ measureDensities: number[];
+ sameFootRuns: number[];
+ footSwitchRatio: number;
+ smallSpinCount: number;
+ bigSpinCount: number;
+}
+
+export interface ChartValidationIssue {
+ code: string;
+ severity: ChartIssueSeverity;
+ message: string;
+ timeSec: number | null;
+ beat: number | null;
+ penalty: number;
+}
+
+export interface ChartValidationResult {
+ valid: boolean;
+ score: number;
+ issues: ChartValidationIssue[];
+ metrics: Record
;
+}
+
+export interface ChartModelProvenance {
+ schemaVersion: string | number | null;
+ architecture: string | null;
+ createdAt: string | null;
+ datasetFingerprint: string | null;
+ sampleCount: number | null;
+ bestLoss: number | null;
+ realDataOnly: boolean;
+ checkpointSha256: string | null;
+}
+
+export interface ChartDocument {
+ id: string;
+ title: string;
+ artist: string;
+ music: string;
+ sourceGroup: string | null;
+ sourcePath: string | null;
+ mode: ChartMode;
+ laneCount: number;
+ difficulty: string;
+ meter: number;
+ bpm: number;
+ offsetSec: number;
+ durationSec: number;
+ measureCount: number;
+ tempoMap: ChartTempoPoint[];
+ events: ChartEvent[];
+ statistics: ChartStatistics | null;
+ validation: ChartValidationResult | null;
+ optimization: Record | null;
+ modelProvenance: ChartModelProvenance | null;
+ generator: string;
+ generatorVersion: string;
+ seed: number | null;
+ spinEnabled?: boolean;
+}
+
+export interface ReferenceChartSummary {
+ id: string;
+ title: string;
+ group: ReferenceChartGroup;
+ mode: ChartMode;
+ laneCount: number;
+ difficulty: string;
+ meter: number;
+ bpm: number;
+ bpmMax: number;
+ offsetSec: number;
+ durationSec: number;
+ noteCount: number;
+ eventCount: number;
+ npsAverage: number;
+ npsPeak: number;
+ audioUrl: string;
+ chartUrl: string;
+}
+
+export interface ReferenceChartListResponse {
+ items: ReferenceChartSummary[];
+ total: number;
+ corpusTotal: number;
+ source: 'local_reference_corpus' | string;
+}
+
+export interface ChartCorpusStatistics {
+ chartCount: number;
+ songCount: number;
+ singleChartCount: number;
+ singleSongCount: number;
+ doubleChartCount: number;
+ doubleSongCount: number;
+ difficultyMin: number;
+ difficultyMax: number;
+ totalNotes: number;
+ totalDurationSec: number;
+ averageNps: number;
+ groups: Record;
+ laneTransitionProbabilities: number[][];
+ meterProfiles: Record>;
+}
+
+export interface GenerateChartRequest {
+ difficulty: number;
+ enableSpin: boolean;
+ useLocalModel?: boolean;
+ seed?: number;
+}
+
+export interface ChartGenerationResponse {
+ generationId: string;
+ chart: ChartDocument;
+ referenceCorpus: {
+ source: string;
+ chartCount: number;
+ songCount: number;
+ difficultyRange: number[];
+ model: {
+ requested: boolean;
+ available: boolean;
+ used: boolean;
+ };
+ };
+}
diff --git a/apps/web/src/utils/chartPreview.ts b/apps/web/src/utils/chartPreview.ts
new file mode 100644
index 0000000..e63f8ba
--- /dev/null
+++ b/apps/web/src/utils/chartPreview.ts
@@ -0,0 +1,97 @@
+import type { ChartEvent, ChartNoteType } from '../types';
+
+export const FIVE_PANEL_LANES = [0, 1, 2, 3, 4] as const;
+export type FivePanelLane = (typeof FIVE_PANEL_LANES)[number];
+
+export const CHART_SCROLL_SPEED_OPTIONS = [1, 2, 3, 4, 5, 6, 8] as const;
+export type ChartScrollSpeed = (typeof CHART_SCROLL_SPEED_OPTIONS)[number];
+export const DEFAULT_CHART_SCROLL_SPEED: ChartScrollSpeed = 4;
+export const BASE_CHART_APPROACH_SECONDS = 2.6;
+
+export const FIVE_PANEL_LABELS: Record = {
+ 0: '左下',
+ 1: '左上',
+ 2: '中心',
+ 3: '右上',
+ 4: '右下',
+};
+
+export function isChartScrollSpeed(value: number): value is ChartScrollSpeed {
+ return CHART_SCROLL_SPEED_OPTIONS.includes(value as ChartScrollSpeed);
+}
+
+export function chartApproachSeconds(
+ scrollSpeed: number,
+ baseApproachSeconds = BASE_CHART_APPROACH_SECONDS,
+): number {
+ const safeBase = Number.isFinite(baseApproachSeconds) && baseApproachSeconds > 0
+ ? baseApproachSeconds
+ : BASE_CHART_APPROACH_SECONDS;
+ if (!Number.isFinite(scrollSpeed) || scrollSpeed <= 0) return safeBase;
+ return safeBase / scrollSpeed;
+}
+
+export interface RenderableChartNote {
+ key: string;
+ lane: FivePanelLane;
+ type: ChartNoteType;
+ startTimeSec: number;
+ endTimeSec: number | null;
+ beat: number;
+ measure: number;
+ pattern: string | null;
+}
+
+export function flattenFivePanelEvents(events: readonly ChartEvent[]): RenderableChartNote[] {
+ const notes: RenderableChartNote[] = [];
+ events.forEach((event, eventIndex) => {
+ event.notes.forEach((note, noteIndex) => {
+ if (!FIVE_PANEL_LANES.includes(note.lane as FivePanelLane)) return;
+ notes.push({
+ key: `${event.sourceEventId ?? eventIndex}:${noteIndex}:${note.lane}`,
+ lane: note.lane as FivePanelLane,
+ type: note.type,
+ startTimeSec: event.timeSec,
+ endTimeSec: note.endTimeSec,
+ beat: event.beat,
+ measure: event.measure,
+ pattern: event.pattern,
+ });
+ });
+ });
+ return notes.sort((left, right) => (
+ left.startTimeSec - right.startTimeSec || left.lane - right.lane
+ ));
+}
+
+export function visibleChartNotes(
+ notes: readonly RenderableChartNote[],
+ currentTimeSec: number,
+ approachSeconds: number,
+ missWindowSeconds = 0.35,
+): RenderableChartNote[] {
+ const latestStart = currentTimeSec + Math.max(0, approachSeconds);
+ const earliestVisibleEnd = currentTimeSec - Math.max(0, missWindowSeconds);
+ return notes.filter((note) => (
+ note.startTimeSec <= latestStart
+ && (note.endTimeSec ?? note.startTimeSec) >= earliestVisibleEnd
+ ));
+}
+
+export function chartNoteY(
+ eventTimeSec: number,
+ currentTimeSec: number,
+ judgmentLineY: number,
+ travelDistance: number,
+ approachSeconds: number,
+): number {
+ if (!Number.isFinite(approachSeconds) || approachSeconds <= 0) return judgmentLineY;
+ return judgmentLineY
+ + ((eventTimeSec - currentTimeSec) / approachSeconds) * Math.max(0, travelDistance);
+}
+
+export function clampPlaybackTime(timeSec: number, durationSec: number): number {
+ if (!Number.isFinite(timeSec)) return 0;
+ const safeDuration = Number.isFinite(durationSec) ? Math.max(0, durationSec) : 0;
+ return Math.max(0, Math.min(safeDuration, timeSec));
+}
diff --git a/apps/web/src/utils/generationSeed.ts b/apps/web/src/utils/generationSeed.ts
new file mode 100644
index 0000000..34b9d0a
--- /dev/null
+++ b/apps/web/src/utils/generationSeed.ts
@@ -0,0 +1,23 @@
+const GENERATION_SEED_RANGE = 0x1_0000_0000;
+
+function initialGenerationSeed(): number {
+ if (typeof globalThis.crypto?.getRandomValues === 'function') {
+ const value = new Uint32Array(1);
+ globalThis.crypto.getRandomValues(value);
+ return value[0] ?? 0;
+ }
+ return Date.now() % GENERATION_SEED_RANGE;
+}
+
+/**
+ * Returns a stable per-workspace sequence so every regeneration gets a new,
+ * explicit seed while requests with an explicitly reused seed remain reproducible.
+ */
+export function createGenerationSeedSequence(start = initialGenerationSeed()): () => number {
+ let nextSeed = Math.trunc(start) >>> 0;
+ return () => {
+ const seed = nextSeed;
+ nextSeed = (nextSeed + 1) % GENERATION_SEED_RANGE;
+ return seed;
+ };
+}
diff --git a/apps/web/tests/chartGenerationPage.test.tsx b/apps/web/tests/chartGenerationPage.test.tsx
new file mode 100644
index 0000000..e17199e
--- /dev/null
+++ b/apps/web/tests/chartGenerationPage.test.tsx
@@ -0,0 +1,122 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { api } from '../src/api/client';
+import { ChartGenerationPage } from '../src/pages/ChartGenerationPage';
+import type { ChartGenerationResponse, GenerateChartRequest, ProjectDetail } from '../src/types';
+import { hit, sampleRate, tempo } from './fixtures';
+import { referenceExcerptChart } from './syntheticChartFixtures';
+
+const trackId = 'track-generation';
+
+const project: ProjectDetail = {
+ id: 'project-generation',
+ title: 'Regeneration Test',
+ artist: 'BeatForge',
+ genre: 'SPEED',
+ coverUrl: '',
+ status: 'completed',
+ createdAt: '2026-07-21T00:00:00.000Z',
+ updatedAt: '2026-07-21T00:00:00.000Z',
+ trackId,
+ track: {
+ id: trackId,
+ projectId: 'project-generation',
+ createdAt: '2026-07-21T00:00:00.000Z',
+ updatedAt: '2026-07-21T00:00:00.000Z',
+ originalFileName: 'regeneration.mp3',
+ audioUrl: `/api/tracks/${trackId}/audio`,
+ format: 'mp3',
+ originalSampleRate: sampleRate,
+ channels: 2,
+ sampleCount: Math.round(referenceExcerptChart.durationSec * sampleRate),
+ durationSec: referenceExcerptChart.durationSec,
+ leadingSilenceSamples: 0,
+ analysis: null,
+ tempoMap: [tempo],
+ hitPoints: [hit()],
+ candidateEvents: [],
+ stems: [{ source: 'mix', available: true, waveformUrl: '/waveform' }],
+ focusMap: [],
+ waveformUrl: '/waveform',
+ },
+};
+
+function response(index: number, seed: number): ChartGenerationResponse {
+ const id = `generated-chart-${index}`;
+ return {
+ generationId: id,
+ chart: { ...referenceExcerptChart, id, title: project.title, seed },
+ referenceCorpus: {
+ source: 'local reference corpus',
+ chartCount: 12,
+ songCount: 9,
+ difficultyRange: [1, 15],
+ model: { requested: true, available: true, used: true },
+ },
+ };
+}
+
+function renderPage() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return render(
+
+
+
+ } />
+
+
+ ,
+ );
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('ChartGenerationPage regeneration', () => {
+ it('uses a fresh explicit seed, displays the new generation, and resets playback each time', async () => {
+ const user = userEvent.setup();
+ const requests: GenerateChartRequest[] = [];
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
+ vi.spyOn(api, 'getProject').mockResolvedValue(project);
+ vi.spyOn(api, 'getLatestChart').mockResolvedValue({
+ ...referenceExcerptChart,
+ id: 'latest-chart',
+ title: project.title,
+ });
+ vi.spyOn(api, 'generateChart').mockImplementation(async (_requestedTrackId, input) => {
+ requests.push(input);
+ return response(requests.length, input.seed!);
+ });
+
+ renderPage();
+
+ const regenerate = await screen.findByRole('button', { name: '重新生成谱面' });
+ const difficultySelect = screen.getByRole('combobox', { name: '谱面难度' });
+ expect(difficultySelect).toHaveValue(String(referenceExcerptChart.meter));
+ expect(difficultySelect.querySelectorAll('option')).toHaveLength(15);
+ await user.selectOptions(difficultySelect, '11');
+ await user.click(regenerate);
+ await waitFor(() => expect(screen.getByRole('link', { name: '导出 SM' }))
+ .toHaveAttribute('href', expect.stringContaining('generationId=generated-chart-1')));
+
+ const firstPosition = screen.getByRole('slider', { name: '播放位置' });
+ fireEvent.change(firstPosition, { target: { value: '12' } });
+ expect((firstPosition as HTMLInputElement).value).toBe('12');
+
+ await user.click(screen.getByRole('button', { name: '重新生成谱面' }));
+ await waitFor(() => expect(screen.getByRole('link', { name: '导出 SM' }))
+ .toHaveAttribute('href', expect.stringContaining('generationId=generated-chart-2')));
+
+ expect(requests).toHaveLength(2);
+ expect(requests[0]).toMatchObject({ difficulty: 11, enableSpin: false, useLocalModel: true });
+ expect(requests[0]?.seed).toEqual(expect.any(Number));
+ expect(requests[1]?.seed).toEqual(expect.any(Number));
+ expect(requests[1]?.seed).not.toBe(requests[0]?.seed);
+ expect((screen.getByRole('slider', { name: '播放位置' }) as HTMLInputElement).value).toBe('0');
+ expect(screen.getByTestId('five-lane-preview')).toHaveAttribute('data-current-time', '0.000');
+ });
+});
diff --git a/apps/web/tests/chartPlayer.test.tsx b/apps/web/tests/chartPlayer.test.tsx
new file mode 100644
index 0000000..e5abe0a
--- /dev/null
+++ b/apps/web/tests/chartPlayer.test.tsx
@@ -0,0 +1,33 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { ChartPlayer } from '../src/components/ChartPlayer';
+import { referenceExcerptChart } from './syntheticChartFixtures';
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('ChartPlayer scroll speed', () => {
+ it('defaults to 4x visual speed and never changes the audio playback rate', () => {
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
+ const { container } = render(
+ ,
+ );
+
+ const audio = container.querySelector('audio');
+ expect(audio).not.toBeNull();
+ expect(audio?.playbackRate).toBe(1);
+ expect(screen.getByRole('combobox', { name: '谱面流速' })).toHaveValue('4');
+ expect(screen.getByTestId('five-lane-preview')).toHaveAttribute('data-scroll-speed', '4');
+
+ fireEvent.change(screen.getByRole('combobox', { name: '谱面流速' }), {
+ target: { value: '8' },
+ });
+
+ expect(audio?.playbackRate).toBe(1);
+ expect(screen.getByTestId('five-lane-preview')).toHaveAttribute('data-scroll-speed', '8');
+ expect(screen.getByTestId('five-lane-preview')).toHaveAttribute('data-approach-seconds', '0.325');
+ });
+});
diff --git a/apps/web/tests/chartPreview.test.ts b/apps/web/tests/chartPreview.test.ts
new file mode 100644
index 0000000..1623885
--- /dev/null
+++ b/apps/web/tests/chartPreview.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+import {
+ chartApproachSeconds,
+ chartNoteY,
+ clampPlaybackTime,
+ flattenFivePanelEvents,
+ visibleChartNotes,
+} from '../src/utils/chartPreview';
+import { referenceExcerptEvents } from './syntheticChartFixtures';
+
+describe('five-lane chart preview math with synthetic events', () => {
+ it('maps visual scroll speed to a shorter approach window without changing time', () => {
+ expect(chartApproachSeconds(1)).toBe(2.6);
+ expect(chartApproachSeconds(4)).toBe(0.65);
+ expect(chartApproachSeconds(8)).toBe(0.325);
+ expect(chartApproachSeconds(0)).toBe(2.6);
+ });
+
+ it('flattens taps, a hold, and a simultaneous jump without losing panel lanes', () => {
+ const notes = flattenFivePanelEvents(referenceExcerptEvents);
+ expect(notes).toHaveLength(4);
+ expect(notes.map((note) => note.lane)).toEqual([0, 4, 0, 2]);
+ expect(notes.find((note) => note.type === 'hold')).toMatchObject({
+ startTimeSec: 2,
+ endTimeSec: 2.5,
+ lane: 4,
+ });
+ });
+
+ it('places an event on the judgment line at its absolute parser time and future notes below it', () => {
+ expect(chartNoteY(2, 2, 112, 444, 2.6)).toBe(112);
+ expect(chartNoteY(2, 1.5, 112, 444, 2.6)).toBeGreaterThan(112);
+ expect(chartNoteY(2, 2.25, 112, 444, 2.6)).toBeLessThan(112);
+ });
+
+ it('keeps a hold visible after its head crosses the judgment line until its tail passes', () => {
+ const notes = flattenFivePanelEvents(referenceExcerptEvents);
+ expect(visibleChartNotes(notes, 2.25, 2.6).some((note) => note.type === 'hold')).toBe(true);
+ expect(visibleChartNotes(notes, 2.9, 2.6).some((note) => note.type === 'hold')).toBe(false);
+ });
+
+ it('clamps rewind and fast-forward to the configured audio duration', () => {
+ expect(clampPlaybackTime(-5, 20)).toBe(0);
+ expect(clampPlaybackTime(30, 20)).toBe(20);
+ expect(clampPlaybackTime(6, 20)).toBe(6);
+ });
+});
diff --git a/apps/web/tests/chartTransport.test.tsx b/apps/web/tests/chartTransport.test.tsx
new file mode 100644
index 0000000..6abafc7
--- /dev/null
+++ b/apps/web/tests/chartTransport.test.tsx
@@ -0,0 +1,39 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { ChartTransport } from '../src/components/ChartTransport';
+
+describe('ChartTransport', () => {
+ it('exposes play, pause-sized skips, and sample-accurate slider seeking', () => {
+ const onTogglePlay = vi.fn();
+ const onSeek = vi.fn();
+ const onSeekBy = vi.fn();
+ const onScrollSpeedChange = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: '播放谱面预览' }));
+ fireEvent.click(screen.getByRole('button', { name: '快退 5 秒' }));
+ fireEvent.click(screen.getByRole('button', { name: '快进 5 秒' }));
+ fireEvent.change(screen.getByRole('slider', { name: '播放位置' }), { target: { value: '42.125' } });
+ fireEvent.change(screen.getByRole('combobox', { name: '谱面流速' }), { target: { value: '6' } });
+
+ expect(onTogglePlay).toHaveBeenCalledOnce();
+ expect(onSeekBy).toHaveBeenNthCalledWith(1, -5);
+ expect(onSeekBy).toHaveBeenNthCalledWith(2, 5);
+ expect(onSeek).toHaveBeenCalledWith(42.125);
+ expect(onScrollSpeedChange).toHaveBeenCalledWith(6);
+ expect(screen.getByRole('combobox', { name: '谱面流速' })).toHaveValue('4');
+ expect(screen.getByText('0:18.250')).toBeVisible();
+ expect(screen.getByText('/ 2:04.500')).toBeVisible();
+ });
+});
diff --git a/apps/web/tests/chartValidationPanel.test.tsx b/apps/web/tests/chartValidationPanel.test.tsx
new file mode 100644
index 0000000..1ac2cc4
--- /dev/null
+++ b/apps/web/tests/chartValidationPanel.test.tsx
@@ -0,0 +1,26 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { ChartValidationPanel } from '../src/components/ChartValidationPanel';
+import { referenceExcerptChart } from './syntheticChartFixtures';
+
+describe('ChartValidationPanel full-step result', () => {
+ it('shows the strict alternating no-spin guarantee returned by the validator', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('FULL STEP · 全步伐')).toBeInTheDocument();
+ expect(screen.getByText('严格左右交替 · 无转圈')).toBeInTheDocument();
+ expect(screen.getByText('通过')).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/tests/editorToolbar.test.tsx b/apps/web/tests/editorToolbar.test.tsx
index 47e47ba..436d5c4 100644
--- a/apps/web/tests/editorToolbar.test.tsx
+++ b/apps/web/tests/editorToolbar.test.tsx
@@ -158,7 +158,11 @@ describe('EditorToolbar', () => {
'href',
`/api/tracks/${inputTrack.id}/export?format=package&audio=reference`,
);
- expect(within(actions).getAllByRole('link')).toHaveLength(1);
+ expect(within(actions).getByRole('link', { name: 'AI Chart' })).toHaveAttribute(
+ 'href',
+ `/projects/${project(inputTrack).id}/chart`,
+ );
+ expect(within(actions).getAllByRole('link')).toHaveLength(2);
expect(within(actions).queryByText('仅数据包')).not.toBeInTheDocument();
expect(within(actions).queryByText('完整分轨包')).not.toBeInTheDocument();
expect(within(actions).queryByText('导出 JSON')).not.toBeInTheDocument();
diff --git a/apps/web/tests/fiveLaneChartPreview.test.tsx b/apps/web/tests/fiveLaneChartPreview.test.tsx
new file mode 100644
index 0000000..7e135b8
--- /dev/null
+++ b/apps/web/tests/fiveLaneChartPreview.test.tsx
@@ -0,0 +1,68 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { FiveLaneChartPreview } from '../src/components/FiveLaneChartPreview';
+import { referenceExcerptChart } from './syntheticChartFixtures';
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('FiveLaneChartPreview', () => {
+ it('renders five labeled gameplay lanes and the fixture hold on a Canvas', async () => {
+ const context = new Proxy(
+ {
+ fillText: vi.fn(),
+ fillRect: vi.fn(),
+ lineTo: vi.fn(),
+ moveTo: vi.fn(),
+ arc: vi.fn(),
+ } as Record,
+ {
+ get(target, property) {
+ if (!(property in target)) target[property] = vi.fn();
+ return target[property];
+ },
+ },
+ ) as unknown as CanvasRenderingContext2D;
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
+
+ render();
+
+ expect(screen.getByTestId('five-lane-preview')).toHaveAttribute('data-current-time', '2.500');
+ expect(screen.getByTestId('five-lane-preview')).toHaveAttribute('data-scroll-speed', '4');
+ expect(screen.getByTestId('five-lane-preview')).toHaveAttribute('data-approach-seconds', '0.650');
+ expect(screen.getByLabelText(/五轨谱面预览:Synthetic reference chart/)).toBeVisible();
+ await waitFor(() => {
+ for (const label of ['左下', '左上', '中心', '右上', '右下']) {
+ expect(context.fillText).toHaveBeenCalledWith(label, expect.any(Number), 28);
+ }
+ });
+ expect(context.fillRect).toHaveBeenCalledWith(
+ expect.any(Number),
+ expect.any(Number),
+ expect.any(Number),
+ expect.any(Number),
+ );
+ });
+
+ it('matches a layout-constrained preview height instead of forcing the legacy fixed height', async () => {
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function getWidth(
+ this: HTMLElement,
+ ) {
+ return this instanceof HTMLCanvasElement ? 300 : 720;
+ });
+ vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function getHeight(
+ this: HTMLElement,
+ ) {
+ return this instanceof HTMLCanvasElement ? 150 : 420;
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('five-lane-chart-canvas')).toHaveStyle({
+ width: '720px',
+ height: '420px',
+ });
+ });
+ });
+});
diff --git a/apps/web/tests/generationSeed.test.ts b/apps/web/tests/generationSeed.test.ts
new file mode 100644
index 0000000..94878ca
--- /dev/null
+++ b/apps/web/tests/generationSeed.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest';
+import { createGenerationSeedSequence } from '../src/utils/generationSeed';
+
+describe('createGenerationSeedSequence', () => {
+ it('returns a different explicit seed for every generation in a workspace', () => {
+ const takeSeed = createGenerationSeedSequence(41);
+
+ expect([takeSeed(), takeSeed(), takeSeed()]).toEqual([41, 42, 43]);
+ });
+
+ it('keeps seeds in the unsigned 32-bit range when the sequence wraps', () => {
+ const takeSeed = createGenerationSeedSequence(0xffff_ffff);
+
+ expect(takeSeed()).toBe(0xffff_ffff);
+ expect(takeSeed()).toBe(0);
+ });
+});
diff --git a/apps/web/tests/syntheticChartFixtures.ts b/apps/web/tests/syntheticChartFixtures.ts
new file mode 100644
index 0000000..2197c65
--- /dev/null
+++ b/apps/web/tests/syntheticChartFixtures.ts
@@ -0,0 +1,80 @@
+import type { ChartDocument, ChartEvent, ChartNote } from '../src/types';
+
+const tap = (lane: number): ChartNote => ({
+ lane,
+ type: 'tap',
+ endTimeSec: null,
+ endBeat: null,
+ source: 'synthetic-test',
+ confidence: 1,
+ foot: null,
+});
+
+// Generated fixture: 120 BPM, zero offset, and deliberately simple event times.
+// It is not copied or measured from any user song or reference corpus.
+export const referenceExcerptEvents: ChartEvent[] = [
+ {
+ timeSec: 1,
+ beat: 2,
+ measure: 0,
+ subdivision: 4,
+ rowIndex: 2,
+ notes: [tap(0)],
+ sourceEventId: null,
+ pattern: null,
+ },
+ {
+ timeSec: 2,
+ beat: 4,
+ measure: 1,
+ subdivision: 4,
+ rowIndex: 0,
+ notes: [{
+ lane: 4,
+ type: 'hold',
+ endTimeSec: 2.5,
+ endBeat: 5,
+ source: 'synthetic-test',
+ confidence: 1,
+ foot: null,
+ }],
+ sourceEventId: null,
+ pattern: null,
+ },
+ {
+ timeSec: 6,
+ beat: 12,
+ measure: 3,
+ subdivision: 4,
+ rowIndex: 0,
+ notes: [tap(0), tap(2)],
+ sourceEventId: null,
+ pattern: null,
+ },
+];
+
+export const referenceExcerptChart: ChartDocument = {
+ id: 'synthetic-reference-chart',
+ title: 'Synthetic reference chart',
+ artist: 'BeatForge Test Lab',
+ music: 'synthetic-reference.wav',
+ sourceGroup: 'SPEED_CLUB',
+ sourcePath: 'SPEED_CLUB/synthetic-reference/synthetic-reference_Lv5.sm',
+ mode: 'pump-single',
+ laneCount: 5,
+ difficulty: 'Hard',
+ meter: 5,
+ bpm: 120,
+ offsetSec: 0,
+ durationSec: 20,
+ measureCount: 10,
+ tempoMap: [{ beat: 0, bpm: 120, timeSec: 0 }],
+ events: referenceExcerptEvents,
+ statistics: null,
+ validation: null,
+ optimization: null,
+ modelProvenance: null,
+ generator: 'synthetic_test_fixture',
+ generatorVersion: '1.0',
+ seed: null,
+};
diff --git a/docs/AI_CHART_ENGINE.md b/docs/AI_CHART_ENGINE.md
new file mode 100644
index 0000000..2dcf74b
--- /dev/null
+++ b/docs/AI_CHART_ENGINE.md
@@ -0,0 +1,160 @@
+# BeatForge AI Chart Engine
+
+AI Chart Engine 是 BeatForge Studio 的全本地五轨制谱工作流。它直接读取本地授权语料中
+`SPEED_CLUB`、`SPEED_DEVIL` 与 `SPEED_REMIX` 的原始 `.sm` 和配套音频,以现有
+BeatForge 声学候选为时间锚点,生成、优化、验证并导出可玩的 `pump-single` 谱面。
+
+运行时不调用 LLM 或云端 API。语料、特征、模型 checkpoint 和生成结果全部保存在本机,
+且 `local-data/`、兼容旧目录 `材料/` 与 `storage/` 都不应提交到 Git。
+
+## 本地参考语料
+
+默认语料目录是:
+
+```text
+local-data/speed-corpus/
+├── SPEED_CLUB/
+├── SPEED_DEVIL/
+└── SPEED_REMIX/
+```
+
+谱面、模式和歌曲数量取决于开发者自行配置并获授权的本地语料。解析器保留变速 BPM 表、
+Offset、任意行细分、Tap、Hold 与 Mine,并能恢复源文件中出现的孤立 Hold tail。
+
+如果语料在其他位置,通过 `.env` 指定:
+
+```dotenv
+BEATFORGE_SPEED_CHARTS_DIR=/absolute/path/to/licensed-speed-corpus
+```
+
+检查解析后的语料统计:
+
+```bash
+.venv/bin/python scripts/chart_engine.py inventory
+```
+
+## 本地训练数据集
+
+模型只训练五轨谱面。下面的命令对语料中的 `pump-single` 谱面逐首运行生产版 BeatForge
+分析器,并在 `storage/chart-engine/dataset/` 生成完整训练三元组:
+
+```bash
+.venv/bin/python scripts/chart_engine.py build-dataset \
+ --mode pump-single \
+ --analysis-mode balanced \
+ --analyze-missing
+```
+
+首次运行会分析真实音频,耗时取决于 CPU/GPU;以后会按音频 SHA-256 复用
+`.feature-cache/`。同一音频的不同谱面使用相同的 train/validation/test split,避免音频泄漏。
+未带 `--analyze-missing` 时,缺少真实分析的样本会被明确记录为跳过,不会生成空候选或占位特征。
+如果 MP3 能读取元数据但完整 libsndfile 解码失败,构建器会自动使用本地 FFmpeg 解码为临时
+PCM 再运行同一个分析器,并在分析 JSON 中记录 `source_decode_backend`;临时音频随后删除。
+
+每个样本目录包含:
+
+```text
+/
+├── audio.mp3 原始真实音频的硬链接、符号链接或本地副本
+├── beatforge.json 生产版 BeatForge 分析与候选事件
+├── chart.json 解析后的真实 SM 目标
+└── metadata.json 来源、难度、哈希、split 与 realData 标记
+```
+
+数据集根目录还包含 `manifest.json`、`build_report.json` 与 `chart_statistics.json`。
+
+## 本地 Transformer
+
+安装可选 PyTorch 依赖后训练:
+
+```bash
+.venv/bin/pip install -e 'apps/api[chart-ml]'
+
+.venv/bin/python scripts/train_chart_model.py \
+ --dataset storage/chart-engine/dataset \
+ --output storage/chart-engine/models/chart-transformer.pt \
+ --epochs 12 \
+ --batch-size 8 \
+ --sequence-length 512 \
+ --device auto
+```
+
+模型是带难度条件的 Transformer encoder。输入为当前歌曲真实 BeatForge candidate 序列,输出
+五个独立面板概率和 Hold 概率。Checkpoint 记录固定特征 schema、归一化参数、数据集指纹、
+真实样本来源、训练/验证损失、Torch 版本和设备,不包含音频本体。
+
+当 `chart-transformer.pt` 存在时,生成接口默认使用它;checkpoint 不存在时会明确回退到基于
+本地参考语料统计的确定性规则生成器。请求传入 `useLocalModel: false` 可主动关闭模型。损坏或
+不兼容的 checkpoint 会返回 `LOCAL_CHART_MODEL_FAILED`,不会静默伪装成模型结果。
+
+## 生成、优化与验证
+
+生成器把 BeatForge `accepted` candidate 与持久化 hitPoint 当作节奏骨架;模型的五轨概率只参与
+选键和 Hold,不再被误当成“事件是否存在”的概率。可选的 uncertain/rejected candidate 才会接受
+模型阈值和难度预算筛选。同一量化槽只生成一个时序事件,同时在 `sourceEventIds`、
+`sourceHitPointIds` 中保留合并前的完整来源,`sourceEventId` 继续作为兼容主 ID。
+
+节奏网格随难度分级:Lv.1–3 为 1/4,Lv.4–7 为 1/8,Lv.8–10 最高为 1/16;只有 Lv.11–15
+能根据低网格置信度的真实声学位置混合 1/16 与 1/24。连续 1/16 密集段保留为高压单键流,
+随机或模型双押必须同时满足前后间距,不能用一个双押冒充两个参考标记。`enableSpin` 是独立开关,
+默认关闭,开启后可插入三键小圈与五键大圈。
+
+确定性优化器在密度超限时先把可选双押降为单键、再删除低置信可选事件;accepted marker 与
+hitPoint 的时序行不会被静默删除。优化器和验证器共享按真实 BPM 校准的两秒整数 note 容量,
+连续 1/16 可以通过,双押和持续 1/24 仍按每个 note 单独计数。验证器还会复核难度允许的实际
+细分、同脚 16 分连续、身体位移、多键同时踩踏和 Hold 生命周期,并返回 0–100 分与逐项问题。
+
+## API
+
+本地授权语料:
+
+- `GET /api/chart-engine/reference-charts`:筛选五轨/十轨、分组与标题。
+- `GET /api/chart-engine/reference-charts/{chartId}`:读取完整绝对时间谱面。
+- `GET|HEAD /api/chart-engine/reference-charts/{chartId}/audio`:支持 HTTP Range 的真实音频。
+- `GET /api/chart-engine/statistics`:BPM、NPS、动作、脚法、转圈与轨道转移统计。
+
+项目歌曲:
+
+- `POST /api/tracks/{trackId}/chart/generate`:生成并验证谱面。
+- `GET /api/tracks/{trackId}/chart/latest`:读取最近生成结果。
+- `GET /api/tracks/{trackId}/chart/export?generationId=...`:导出 UTF-8 BOM `.sm`。
+
+生成请求示例:
+
+```json
+{
+ "difficulty": 10,
+ "enableSpin": false,
+ "useLocalModel": true,
+ "seed": 20260721
+}
+```
+
+响应中的 `chart.generator` 明确区分 `local_chart_transformer` 与规则生成器,
+`chart.modelProvenance` 记录模型数据集指纹、checkpoint SHA-256 与 `realDataOnly` 标记;
+`chart.optimization` 同时记录 accepted/hitPoint 锚点的输入输出行数,因此不同权重不会覆盖同一
+生成版本,参考点覆盖也可逐次审计。
+
+## 前端工作区
+
+启动开发环境后:
+
+- `/chart-engine` 浏览本地五轨参考谱面、语料统计与同步播放器。
+- `/projects/{projectId}/chart` 为项目歌曲生成 1–15 难度谱面、切换转圈、查看验证结果并导出 SM。
+
+Canvas 使用绝对 `timeSec` 绘制五条面板,音符自下向上接近判定线;HTML Audio 是唯一时钟,
+播放、暂停、前后跳转和拖动进度会保持音频与谱面一致。
+
+## 本地存储
+
+```text
+storage/chart-engine/
+├── dataset/ 真实训练三元组、缓存与清单
+├── models/
+│ └── chart-transformer.pt 本地模型 checkpoint
+└── generated/
+ └── / 版本化 JSON 与 latest.json
+```
+
+这些文件可由真实素材重新构建,默认不进入版本控制。分享训练产物或导出的 SM 前,请确认拥有
+相应音频与谱面的使用权。
diff --git a/scripts/chart_engine.py b/scripts/chart_engine.py
new file mode 100644
index 0000000..951b453
--- /dev/null
+++ b/scripts/chart_engine.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+"""Build and inspect the local real-data BeatForge chart engine."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(PROJECT_ROOT / "apps" / "api"))
+
+from beatforge_api.chart_engine.dataset import build_dataset # noqa: E402
+from beatforge_api.chart_engine.library import ReferenceLibrary # noqa: E402
+from beatforge_api.config import get_settings # noqa: E402
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ subparsers = parser.add_subparsers(dest="command", required=True)
+ subparsers.add_parser("inventory", help="Inspect the local SPEED reference corpus")
+
+ build = subparsers.add_parser(
+ "build-dataset", help="Build complete real training triples"
+ )
+ build.add_argument("--output", type=Path)
+ build.add_argument(
+ "--mode", choices=("pump-single", "pump-double"), default="pump-single"
+ )
+ build.add_argument(
+ "--analysis-mode",
+ choices=("recall", "balanced", "clean", "accurate"),
+ default="balanced",
+ )
+ build.add_argument("--analyze-missing", action="store_true")
+ build.add_argument("--limit", type=int)
+ return parser
+
+
+def main() -> int:
+ args = _parser().parse_args()
+ settings = get_settings()
+ library = ReferenceLibrary(settings.speed_charts_dir)
+ if args.command == "inventory":
+ stats = library.statistics()
+ print(
+ json.dumps(
+ stats.model_dump(by_alias=True, mode="json"),
+ ensure_ascii=False,
+ indent=2,
+ )
+ )
+ return 0
+ report = build_dataset(
+ library,
+ args.output or settings.chart_dataset_dir,
+ mode=args.mode,
+ analyze_missing=args.analyze_missing,
+ analysis_mode=args.analysis_mode,
+ limit=args.limit,
+ )
+ print(json.dumps(report.to_dict(), ensure_ascii=False, indent=2))
+ return 0 if report.failed == 0 else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/train_chart_model.py b/scripts/train_chart_model.py
new file mode 100644
index 0000000..7704165
--- /dev/null
+++ b/scripts/train_chart_model.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+"""Train the local BeatForge five-lane Transformer from completed real dataset triples."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(PROJECT_ROOT / "apps" / "api"))
+
+from beatforge_api.chart_engine.learning import ( # noqa: E402
+ FEATURE_NAMES,
+ TrainingConfig,
+ train_chart_transformer,
+)
+from beatforge_api.chart_engine.model import ChartTransformerConfig # noqa: E402
+from beatforge_api.config import get_settings # noqa: E402
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--dataset", type=Path)
+ parser.add_argument("--output", type=Path)
+ parser.add_argument("--epochs", type=int, default=12)
+ parser.add_argument("--batch-size", type=int, default=8)
+ parser.add_argument("--learning-rate", type=float, default=3e-4)
+ parser.add_argument("--weight-decay", type=float, default=1e-4)
+ parser.add_argument("--sequence-length", type=int, default=512)
+ parser.add_argument("--sequence-stride", type=int)
+ parser.add_argument("--match-tolerance-ms", type=float, default=80.0)
+ parser.add_argument("--seed", type=int, default=20260721)
+ parser.add_argument("--device", default="auto")
+ parser.add_argument("--no-validation", action="store_true")
+ parser.add_argument("--max-batches-per-epoch", type=int)
+ parser.add_argument("--d-model", type=int, default=96)
+ parser.add_argument("--heads", type=int, default=4)
+ parser.add_argument("--layers", type=int, default=3)
+ parser.add_argument("--feedforward", type=int, default=256)
+ parser.add_argument("--dropout", type=float, default=0.1)
+ return parser
+
+
+def main() -> int:
+ args = _parser().parse_args()
+ settings = get_settings()
+ dataset = (args.dataset or settings.chart_dataset_dir).expanduser().resolve()
+ output = (
+ (args.output or settings.chart_models_dir / "chart-transformer.pt")
+ .expanduser()
+ .resolve()
+ )
+ training = TrainingConfig(
+ epochs=args.epochs,
+ batch_size=args.batch_size,
+ learning_rate=args.learning_rate,
+ weight_decay=args.weight_decay,
+ match_tolerance_ms=args.match_tolerance_ms,
+ sequence_length=args.sequence_length,
+ sequence_stride=args.sequence_stride,
+ seed=args.seed,
+ device=args.device,
+ validation_split=None if args.no_validation else "validation",
+ max_batches_per_epoch=args.max_batches_per_epoch,
+ )
+ model = ChartTransformerConfig(
+ input_dim=len(FEATURE_NAMES),
+ d_model=args.d_model,
+ nhead=args.heads,
+ num_layers=args.layers,
+ dim_feedforward=args.feedforward,
+ dropout=args.dropout,
+ max_sequence_length=args.sequence_length,
+ )
+ result = train_chart_transformer(
+ dataset,
+ output,
+ training=training,
+ model_config=model,
+ )
+ print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())