fffiloni commited on
Commit
25e54ad
·
verified ·
1 Parent(s): f5368d5

Upload 8 files

Browse files
Files changed (4) hide show
  1. frontend/app.css +170 -27
  2. frontend/studio.css +287 -92
  3. frontend/studio.html +79 -71
  4. frontend/studio.js +147 -83
frontend/app.css CHANGED
@@ -1,28 +1,171 @@
1
- body { background: radial-gradient(circle at 50% -20%, #1a1630 0, #0b0e15 34%, #080b11 68%); }
2
- .gradio-container { max-width: 1580px !important; margin: 0 auto; padding: 0 22px 42px !important; background: transparent !important; }
3
- .studio-masthead { margin: 18px 0 16px; padding: 18px 20px; display: flex; align-items: center; justify-content: space-between; border: 1px solid #292f3b; border-radius: 18px; background: linear-gradient(135deg, #111620, #0d1118); box-shadow: 0 15px 45px rgba(0,0,0,.2); }
4
- .studio-brand { display: flex; align-items: center; gap: 13px; }
5
- .studio-logo { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 12px; color: #ddd6fe; background: #6d4aff; font-size: 24px; }
6
- .studio-brand strong, .studio-brand small { display: block; }
7
- .studio-brand strong { color: #f8fafc; font-size: 20px; letter-spacing: -.02em; }
8
- .studio-brand small { margin-top: 2px; color: #7f8999; font-size: 11px; }
9
- .studio-badges { display: flex; gap: 7px; }
10
- .studio-badges span { padding: 6px 9px; border: 1px solid #303746; border-radius: 999px; color: #aab3c2; background: #111722; font-size: 10px; font-weight: 700; }
11
- .studio-badges .gpu-badge { color: #86efac; border-color: #22543d; background: #0c251c; }
12
- #input-grid { margin-bottom: 16px; gap: 16px !important; align-items: stretch !important; flex-wrap: wrap !important; }
13
- .input-card, .settings-card { padding: 19px !important; border: 1px solid #292f3b !important; border-radius: 18px !important; background: linear-gradient(145deg, #111620, #0d1118) !important; box-shadow: 0 14px 36px rgba(0,0,0,.16); }
14
- .input-card h3, .settings-card h3 { margin-top: 0 !important; color: #f1f5f9; }
15
- #transcribe-button { min-height: 48px; border-radius: 11px; font-weight: 750; }
16
- #generate-score-button { min-height: 42px; margin-top: 7px; border-color: #6650c8; border-radius: 10px; color: #ede9fe; background: #1b1730; font-weight: 720; }
17
- #generate-score-button:disabled { border-color: #323846; color: #697383; background: #11161e; }
18
- #studio-viewer { margin: 0 0 16px; border-radius: 18px; }
19
- #downloads-panel { margin: 0 0 28px; overflow: hidden; border: 1px solid #292f3b; border-radius: 15px; background: #0d1118; }
20
- #downloads-row { margin: 0; padding: 4px 14px 14px; display: flex !important; flex-wrap: wrap !important; gap: 10px; border: 0; background: transparent; }
21
- #downloads-row > div { min-width: min(220px, 100%); flex: 1 1 220px !important; }
22
- @media (max-width: 700px) {
23
- .gradio-container { padding: 0 10px 30px !important; }
24
- .studio-masthead { align-items: flex-start; }
25
- .studio-badges { display: none; }
26
- .studio-brand small { max-width: 26ch; }
27
- .input-card, .settings-card { min-width: 100% !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
 
1
+ :root {
2
+ color-scheme: dark;
3
+ --ms-bg: #090b0f;
4
+ --ms-bg-elevated: #0d1016;
5
+ --ms-panel: rgba(18, 21, 29, .86);
6
+ --ms-border: rgba(255, 255, 255, .085);
7
+ --ms-border-strong: rgba(255, 255, 255, .15);
8
+ --ms-text: #f4f5f7;
9
+ --ms-text-soft: #b5bac6;
10
+ --ms-muted: #747b8c;
11
+ --ms-faint: #4f5564;
12
+ --ms-accent: #8b7cff;
13
+ --ms-success: #63d8a2;
14
+ --ms-warning: #f4bf75;
15
+ --ms-radius-xl: 22px;
16
+ --ms-radius-lg: 16px;
17
+ --ms-radius-md: 12px;
18
+ --ms-shadow: 0 28px 80px rgba(0,0,0,.34), inset 0 1px rgba(255,255,255,.025);
19
+ --ms-font: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
20
+ --ms-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
21
+ }
22
+
23
+ :host {
24
+ display: block;
25
+ min-height: 100vh;
26
+ color-scheme: dark;
27
+ background:
28
+ radial-gradient(1000px 620px at 10% -10%, rgba(92,75,202,.18), transparent 62%),
29
+ radial-gradient(780px 500px at 96% 8%, rgba(53,103,175,.12), transparent 66%),
30
+ linear-gradient(180deg, #0a0c11 0%, #090b0f 50%, #080a0d 100%);
31
+ }
32
+
33
+ html, body { min-height: 100%; background: var(--ms-bg) !important; }
34
+ body { margin: 0; color: var(--ms-text); font-family: var(--ms-font); -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
35
+ body::before {
36
+ position: fixed;
37
+ z-index: -2;
38
+ inset: 0;
39
+ content: "";
40
+ pointer-events: none;
41
+ background:
42
+ radial-gradient(1000px 620px at 10% -10%, rgba(92,75,202,.18), transparent 62%),
43
+ radial-gradient(780px 500px at 96% 8%, rgba(53,103,175,.12), transparent 66%),
44
+ linear-gradient(180deg, #0a0c11 0%, #090b0f 50%, #080a0d 100%);
45
+ }
46
+ body::after {
47
+ position: fixed;
48
+ z-index: -1;
49
+ inset: 0;
50
+ content: "";
51
+ pointer-events: none;
52
+ opacity: .28;
53
+ background-image: linear-gradient(rgba(255,255,255,.012) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.012) 1px, transparent 1px);
54
+ background-size: 32px 32px;
55
+ mask-image: linear-gradient(to bottom, black, transparent 78%);
56
+ }
57
+
58
+ .gradio-container, .gradio-container.dark {
59
+ min-height: 100vh !important;
60
+ max-width: none !important;
61
+ margin: 0 !important;
62
+ padding: 0 !important;
63
+ background: transparent !important;
64
+ color: var(--ms-text) !important;
65
+ font-family: var(--ms-font) !important;
66
+ }
67
+ .gradio-container .prose, .gradio-container .prose * { color: inherit; }
68
+ .gradio-container footer { display: none !important; }
69
+
70
+ #app-shell {
71
+ width: min(1240px, calc(100% - 40px));
72
+ margin: 0 auto;
73
+ padding: 34px 0 58px;
74
+ gap: 18px;
75
+ }
76
+
77
+ #masthead, #intro { border: 0 !important; background: transparent !important; }
78
+ #masthead { padding: 16px 4px 24px; }
79
+ #masthead .masthead-inner { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
80
+ #masthead .brand { display: inline-flex; align-items: center; gap: 11px; color: var(--ms-text) !important; font-size: .92rem; font-weight: 680; letter-spacing: -.025em; text-decoration: none; }
81
+ .brand-mark { display: flex; align-items: end; justify-content: center; width: 30px; height: 30px; gap: 2px; padding: 7px; border: 1px solid rgba(139,124,255,.3); border-radius: 9px; background: linear-gradient(145deg, rgba(139,124,255,.2), rgba(95,140,255,.08)); box-shadow: 0 8px 28px rgba(75,60,180,.2), inset 0 1px rgba(255,255,255,.13); }
82
+ .brand-mark i { display: block; width: 2px; border-radius: 3px; background: #d8d4ff; }
83
+ .brand-mark i:nth-child(1) { height: 6px; opacity: .62; }
84
+ .brand-mark i:nth-child(2) { height: 12px; }
85
+ .brand-mark i:nth-child(3) { height: 9px; opacity: .8; }
86
+ .brand-mark i:nth-child(4) { height: 14px; }
87
+ .header-meta { display: flex; align-items: center; gap: 8px; }
88
+ #model-badge, .zerogpu-badge { display: inline-flex; align-items: center; min-height: 30px; padding: 5px 10px; border: 1px solid var(--ms-border); border-radius: 999px; font: 620 .64rem/1 var(--ms-mono); letter-spacing: .07em; text-transform: uppercase; }
89
+ #model-badge { border-color: rgba(139,124,255,.22); background: rgba(139,124,255,.09); color: #cec8ff; }
90
+ #model-badge::before { width: 6px; height: 6px; margin-right: 7px; border-radius: 50%; background: var(--ms-success); box-shadow: 0 0 0 4px rgba(99,216,162,.1), 0 0 12px rgba(99,216,162,.62); content: ""; }
91
+ .zerogpu-badge { background: rgba(255,255,255,.025); color: var(--ms-muted); }
92
+
93
+ #intro { padding: 28px 4px 32px; }
94
+ .intro-copy { max-width: 850px; }
95
+ .eyebrow { display: inline-block; margin-bottom: 16px; color: #aaa1ff; font: 670 .67rem var(--ms-mono); letter-spacing: .13em; text-transform: uppercase; }
96
+ .intro-copy h1 { margin: 0; color: var(--ms-text); font-size: clamp(2.7rem, 7vw, 5.9rem); font-weight: 590; letter-spacing: -.072em; line-height: .94; }
97
+ .intro-copy h1 em { color: transparent; background: linear-gradient(112deg, #d9d5ff 4%, #8b7cff 48%, #5f9cff 94%); -webkit-background-clip: text; background-clip: text; font-style: normal; }
98
+ .intro-copy p { max-width: 690px; margin: 23px 0 0; color: var(--ms-text-soft); font-size: clamp(.95rem,1.8vw,1.1rem); line-height: 1.68; letter-spacing: -.015em; }
99
+
100
+ #studio-grid { align-items: flex-start; gap: 16px; }
101
+ .section-heading { display: flex; align-items: center; gap: 12px; padding: 2px 2px 12px; }
102
+ .section-heading .step-number { color: var(--ms-faint); font: .61rem var(--ms-mono); letter-spacing: .08em; }
103
+ .section-heading h2 { margin: 0 0 4px; color: var(--ms-text); font-size: .9rem; font-weight: 630; letter-spacing: -.02em; }
104
+ .section-heading p { margin: 0; color: var(--ms-muted); font-size: .68rem; line-height: 1.4; }
105
+
106
+ #upload-card, #result-panel {
107
+ position: relative;
108
+ min-width: 0;
109
+ border: 1px solid var(--ms-border) !important;
110
+ border-radius: var(--ms-radius-xl) !important;
111
+ background: linear-gradient(145deg, rgba(20,23,32,.93), rgba(13,16,22,.9)) !important;
112
+ box-shadow: var(--ms-shadow) !important;
113
+ backdrop-filter: blur(22px);
114
+ }
115
+ #upload-card::before, #result-panel::before { position: absolute; top: 0; right: 12%; left: 12%; height: 1px; content: ""; pointer-events: none; background: linear-gradient(90deg, transparent, rgba(255,255,255,.16), transparent); }
116
+ #upload-card { padding: clamp(16px,2.5vw,25px) !important; }
117
+ #result-panel { padding: clamp(12px,2vw,19px) !important; }
118
+
119
+ #audio-input { overflow: hidden; min-height: 214px; border: 1px dashed rgba(255,255,255,.16) !important; border-radius: var(--ms-radius-lg) !important; background: rgba(7,9,13,.46) !important; transition: border-color 180ms ease, background 180ms ease; }
120
+ #audio-input:hover, #audio-input:focus-within { border-color: rgba(139,124,255,.54) !important; background: rgba(139,124,255,.045) !important; }
121
+ #audio-input .wrap, #audio-input > div, #audio-input .upload-container { border: 0 !important; background: transparent !important; }
122
+ #audio-input .upload-container { min-height: 190px; }
123
+ #audio-input button, #audio-input .upload-button { border-color: var(--ms-border-strong) !important; border-radius: 10px !important; background: rgba(255,255,255,.055) !important; color: var(--ms-text) !important; box-shadow: none !important; }
124
+ #audio-input audio { width: 100%; filter: saturate(.75) contrast(.96); }
125
+
126
+ #advanced-settings, #score-settings, #downloads-panel { overflow: hidden; border: 1px solid var(--ms-border) !important; border-radius: var(--ms-radius-md) !important; background: rgba(8,10,14,.42) !important; box-shadow: none !important; }
127
+ #advanced-settings > button, #advanced-settings summary, #score-settings > button, #score-settings summary, #downloads-panel > button, #downloads-panel summary { min-height: 43px; padding-inline: 13px !important; background: transparent !important; color: var(--ms-text-soft) !important; font-size: .76rem !important; font-weight: 580 !important; }
128
+ #advanced-settings > div, #score-settings > div, #downloads-panel > div { border-color: var(--ms-border) !important; background: transparent !important; }
129
+ #advanced-settings label, #score-settings label, #downloads-panel label { color: var(--ms-text-soft) !important; font-size: .73rem !important; }
130
+ #advanced-settings input, #advanced-settings textarea, #advanced-settings select, #score-settings input, #score-settings textarea, #score-settings select { border-color: var(--ms-border) !important; border-radius: 9px !important; background: rgba(255,255,255,.035) !important; color: var(--ms-text) !important; }
131
+ #advanced-settings input[type="range"], #advanced-settings input[type="checkbox"], #score-settings input[type="range"], #score-settings input[type="checkbox"] { accent-color: var(--ms-accent); }
132
+
133
+ #transcribe-button { position: relative; overflow: hidden; width: 100%; min-height: 50px; margin-top: 3px; border: 1px solid rgba(255,255,255,.15) !important; border-radius: 13px !important; background: linear-gradient(128deg, #7a69eb 0%, #675ad2 48%, #4f75d5 100%) !important; box-shadow: 0 10px 30px rgba(76,66,181,.25), inset 0 1px rgba(255,255,255,.2) !important; color: #fff !important; font-size: .9rem !important; font-weight: 650 !important; transition: transform 150ms ease, box-shadow 150ms ease, filter 150ms ease !important; }
134
+ #transcribe-button:hover:not(:disabled) { filter: brightness(1.08); transform: translateY(-1px); box-shadow: 0 14px 38px rgba(76,66,181,.32), inset 0 1px rgba(255,255,255,.22) !important; }
135
+ #transcribe-button:disabled, #transcribe-button[aria-busy="true"] { cursor: wait; filter: saturate(.55) brightness(.8); }
136
+ .cold-note { display: flex; align-items: flex-start; gap: 8px; margin: 2px !important; color: var(--ms-faint); font-size: .64rem; line-height: 1.5; }
137
+ .cold-note > span { width: 6px; height: 6px; flex: none; margin-top: 3px; border-radius: 50%; background: var(--ms-warning); box-shadow: 0 0 8px rgba(244,191,117,.38); }
138
+
139
+ #studio-viewer, #visualizer-host { min-width: 0; margin: 0; padding: 0 !important; border: 0 !important; background: transparent !important; }
140
+ #studio-viewer > .wrap, #studio-viewer > div, #studio-viewer .html-container { min-width: 0; padding: 0 !important; border: 0 !important; background: transparent !important; }
141
+ #score-settings { margin-top: 3px; }
142
+ #generate-score-button { min-height: 42px; margin-top: 5px; border: 1px solid rgba(139,124,255,.44) !important; border-radius: 10px !important; background: rgba(139,124,255,.14) !important; color: #ede9fe !important; font-weight: 680 !important; }
143
+ #generate-score-button:hover:not(:disabled) { background: rgba(139,124,255,.22) !important; }
144
+ #generate-score-button:disabled { border-color: var(--ms-border) !important; background: rgba(255,255,255,.025) !important; color: var(--ms-faint) !important; }
145
+ #downloads-panel { margin-top: 0; }
146
+ #downloads-row { margin: 0; padding: 3px 11px 12px; display: flex !important; flex-wrap: wrap !important; gap: 8px; border: 0; background: transparent; }
147
+ #downloads-row > div { min-width: min(190px,100%); flex: 1 1 190px !important; overflow: hidden; border: 1px solid var(--ms-border) !important; border-radius: 9px !important; background: rgba(255,255,255,.022) !important; }
148
+
149
+ .footnote { display: flex; justify-content: space-between; gap: 16px; padding: 14px 5px 0; color: var(--ms-faint); font: .57rem/1.5 var(--ms-mono); }
150
+
151
+ @media (max-width: 860px) {
152
+ #app-shell { width: min(100% - 24px,1240px); padding-top: 22px; }
153
+ #intro { padding: 22px 3px 27px; }
154
+ #studio-grid { flex-direction: column !important; }
155
+ #upload-card, #result-panel { width: 100% !important; min-width: 100% !important; }
156
+ }
157
+
158
+ @media (max-width: 600px) {
159
+ #app-shell { width: min(100% - 16px,1240px); padding-bottom: 30px; gap: 12px; }
160
+ #masthead .masthead-inner, .footnote { align-items: flex-start; flex-direction: column; }
161
+ .header-meta { width: 100%; }
162
+ .intro-copy h1 { font-size: clamp(2.65rem,15vw,4.2rem); letter-spacing: -.062em; }
163
+ #upload-card, #result-panel { border-radius: 17px !important; }
164
+ #upload-card { padding: 12px !important; }
165
+ #result-panel { padding: 10px !important; }
166
+ #audio-input { min-height: 180px; }
167
+ }
168
+
169
+ @media (prefers-reduced-motion: reduce) {
170
+ *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
171
  }
frontend/studio.css CHANGED
@@ -1,96 +1,291 @@
1
- :root { color-scheme: dark; --bg: #090c11; --panel: #0f141c; --line: #282f3a; --text: #f5f7fb; --muted: #8a94a5; --violet: #8b6df6; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  * { box-sizing: border-box; }
3
- .ms-root { width: 100%; min-width: 0; height: 780px; min-height: 780px; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--line); border-radius: 18px; color: var(--text); background: var(--bg); box-shadow: 0 20px 55px rgba(0,0,0,.24); font-family: Inter, ui-sans-serif, system-ui, sans-serif; container-type: inline-size; }
 
 
 
 
 
 
 
 
 
4
  .ms-root [hidden] { display: none !important; }
5
- .ms-root button, .ms-root input { font: inherit; }
6
- .ms-statusbar { min-height: 76px; flex: 0 0 76px; padding: 15px 18px; display: grid; grid-template-columns: 1fr minmax(180px, 1fr) minmax(220px, .7fr); align-items: center; gap: 20px; border-bottom: 1px solid var(--line); background: #0d1118; }
7
- .ms-status-copy, .ms-song { display: flex; align-items: center; gap: 10px; min-width: 0; }
8
- .ms-status-copy strong, .ms-status-copy span, .ms-song strong, .ms-song span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
9
- .ms-status-copy strong, .ms-song strong { font-size: 12px; }
10
- .ms-status-copy span, .ms-song span { margin-top: 3px; color: var(--muted); font-size: 10px; }
11
- .ms-state-dot { width: 9px; height: 9px; flex: 0 0 auto; border-radius: 50%; background: #64748b; }
12
- .ms-root[data-state="transcribing"] .ms-state-dot { background: #fbbf24; box-shadow: 0 0 12px #fbbf24; }
13
- .ms-root[data-state="ready"] .ms-state-dot, .ms-root[data-state="complete"] .ms-state-dot { background: #4ade80; box-shadow: 0 0 12px #4ade80; }
14
- .ms-root[data-state="error"] .ms-state-dot, .ms-root[data-state="notation_error"] .ms-state-dot { background: #fb7185; }
15
- .ms-progress > div { display: flex; justify-content: space-between; gap: 10px; color: var(--muted); font-size: 9px; }
16
- .ms-progress i { display: block; height: 4px; margin-top: 7px; overflow: hidden; border-radius: 99px; background: #222834; }
17
- .ms-progress i span { display: block; height: 100%; border-radius: inherit; background: var(--violet); transition: width .25s ease; }
18
- .ms-empty { min-height: 0; flex: 1 1 auto; padding: 50px 24px; display: grid; place-content: center; justify-items: center; text-align: center; }
19
- .ms-empty-icon { width: 68px; height: 68px; display: grid; place-items: center; border: 1px solid #35304c; border-radius: 20px; color: #c4b5fd; background: #181426; font-size: 34px; }
20
- .ms-empty h2 { margin: 20px 0 8px; font-size: 20px; }
21
- .ms-empty p { max-width: 48ch; margin: 0; color: var(--muted); font-size: 12px; }
22
- .ms-workspace { min-height: 0; flex: 1 1 auto; display: flex; flex-direction: column; overflow: hidden; }
23
- .ms-transport { min-height: 60px; flex: 0 0 60px; padding: 9px 14px; display: flex; align-items: center; gap: 9px; border-bottom: 1px solid var(--line); background: #0c1017; }
24
- .ms-transport button { border: 0; cursor: pointer; }
25
- .ms-play { width: 38px; height: 38px; border-radius: 50%; color: white; background: #6f4de8; }
26
- .ms-icon-button { width: 28px; height: 28px; border-radius: 7px; color: #8993a2; background: transparent; }
27
- .ms-transport > span { min-width: 45px; color: #aab3c2; font: 600 10px ui-monospace, monospace; }
28
- .ms-transport > input { min-width: 90px; flex: 1; accent-color: var(--violet); }
29
- .ms-transport label { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 9px; }
30
- .ms-transport label input { width: 72px; accent-color: var(--violet); }
31
- .ms-source-mix { height: 32px; padding: 3px; display: flex; align-items: center; gap: 5px; border: 1px solid #303744; border-radius: 9px; background: #111721; }
32
- .ms-source-mix button { height: 24px; padding: 0 7px; border-radius: 6px; color: #9da7b6; background: transparent; font-size: 8px; font-weight: 750; }
33
- .ms-source-mix button[aria-pressed="true"] { color: #fff; background: #5f43ca; }
34
- .ms-source-mix input { width: 70px; accent-color: var(--violet); }
35
- .ms-stereo-toggle { height: 32px; padding: 0 8px; border: 1px solid #303744; border-radius: 9px; white-space: nowrap; background: #111721; cursor: pointer; }
36
- .ms-stereo-toggle input { width: auto !important; }
37
- .ms-stereo-toggle[data-active="true"] { color: #ddd6fe; border-color: #6f57ca; background: #1b1730; }
38
- .ms-synth-state { min-width: 130px !important; padding: 5px 8px; border: 1px solid #343b47; border-radius: 999px; color: #9da7b6 !important; background: #141a23; text-align: center; white-space: nowrap; }
39
- .ms-synth-state[data-kind="loading"] { color: #fde68a !important; border-color: #5b4a20; }
40
- .ms-synth-state[data-kind="ready"] { color: #86efac !important; border-color: #22543d; background: #0c251c; }
41
- .ms-synth-state[data-kind="error"] { color: #fda4af !important; border-color: #5b2631; }
42
- .ms-grid { min-width: 0; min-height: 0; flex: 1 1 auto; display: grid; grid-template-columns: minmax(205px, 250px) minmax(0, 1fr) minmax(185px, 215px); overflow: hidden; }
43
- .ms-tracks-panel, .ms-info-panel { min-width: 0; min-height: 0; overflow: hidden; background: #0d1118; }
44
- .ms-tracks-panel { border-right: 1px solid var(--line); }
45
- .ms-info-panel { padding: 18px 15px; overflow-y: auto; border-left: 1px solid var(--line); }
46
- .ms-tracks-panel > header { min-height: 65px; padding: 14px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--line); }
47
- .ms-tracks-panel > header strong, .ms-kicker { display: block; }
48
- .ms-tracks-panel > header strong { margin-top: 4px; font-size: 12px; }
49
- .ms-kicker { color: #7f8999; font-size: 9px; font-weight: 800; letter-spacing: .16em; }
50
- .ms-tracks-panel > header button { padding: 5px 8px; border: 1px solid #333b48; border-radius: 7px; color: #aab3c2; background: #141a23; cursor: pointer; font-size: 9px; }
51
- .ms-track-list { height: calc(100% - 65px); display: grid; align-content: start; overflow: auto; overscroll-behavior: contain; scrollbar-color: #343b47 transparent; }
52
- .ms-track-row { min-height: 69px; padding: 11px 12px; display: grid; grid-template-columns: 5px minmax(80px, 1fr) auto; align-items: center; gap: 10px; border-bottom: 1px solid #202631; background: #0e131a; }
53
- .ms-track-row[data-inactive="true"] { opacity: .42; }
54
- .ms-track-color { width: 5px; height: 37px; border-radius: 99px; background: var(--track); box-shadow: 0 0 10px color-mix(in srgb, var(--track) 55%, transparent); }
55
- .ms-track-copy { overflow: hidden; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  .ms-track-copy strong, .ms-track-copy span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
57
- .ms-track-copy strong { font-size: 11px; }
58
- .ms-track-copy span { margin-top: 4px; color: var(--muted); font-size: 9px; }
59
- .ms-program-select { width: 100%; max-width: 150px; margin-top: 6px; padding: 3px 20px 3px 5px; overflow: hidden; border: 1px solid #303743; border-radius: 5px; color: #aeb7c5; background: #111721; font-size: 8px; text-overflow: ellipsis; }
60
  .ms-track-controls { display: flex; gap: 4px; }
61
- .ms-track-controls button, .ms-track-controls a { width: 27px; height: 27px; display: grid; place-items: center; border: 1px solid #343b47; border-radius: 6px; color: #949eac; background: #111721; cursor: pointer; font-size: 9px; font-weight: 800; text-decoration: none; }
62
- .ms-track-controls button[aria-pressed="true"] { color: white; border-color: var(--track); background: color-mix(in srgb, var(--track) 25%, #111721); }
63
- .ms-track-controls a[aria-disabled="true"] { opacity: .25; pointer-events: none; }
64
- .ms-main-panel { min-width: 0; min-height: 0; overflow: hidden; background: #090c11; }
65
- .ms-tabs { height: 50px; padding: 0 18px; display: flex; gap: 25px; border-bottom: 1px solid var(--line); background: #0d1118; }
66
- .ms-tabs button { padding: 0 2px; border: 0; border-bottom: 2px solid transparent; color: #7f8999; background: transparent; cursor: pointer; font-size: 10px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
67
- .ms-tabs button[aria-selected="true"] { color: #f1f5f9; border-bottom-color: var(--violet); }
68
- .ms-roll-view { height: calc(100% - 50px); min-height: 0; padding: 14px; display: flex; flex-direction: column; overflow: hidden; }
69
- .ms-roll-toolbar { height: 30px; display: flex; justify-content: space-between; color: var(--muted); font-size: 9px; }
70
- .ms-roll-toolbar label { display: flex; gap: 7px; align-items: flex-start; }
71
- .ms-roll-toolbar input { width: 78px; accent-color: var(--violet); }
72
- .ms-roll-view canvas { width: 100%; min-height: 260px; flex: 1 1 auto; display: block; border: 1px solid #202631; border-radius: 8px; cursor: crosshair; }
73
- .ms-roll-view p { margin: 8px 0 0; color: #566171; font-size: 9px; text-align: right; }
74
- .ms-score-view { height: calc(100% - 50px); min-height: 0; padding: 18px; overflow: auto; overscroll-behavior: contain; background: #11151b; }
75
- .ms-score-empty { min-height: 100%; display: grid; place-content: center; justify-items: center; color: var(--muted); text-align: center; }
76
- .ms-score-empty span { font-size: 40px; color: #8b6df6; }
77
- .ms-score-empty strong { margin-top: 12px; color: #d8dce4; }
78
- .ms-score-empty p { margin: 5px 0 0; font-size: 10px; }
79
- .ms-score-paper { width: max(100%, 720px); min-width: 0; padding: 20px; border-radius: 3px; background: #fffdf7; box-shadow: 0 12px 36px rgba(0,0,0,.3); }
80
- .ms-score-paper svg { display: block; width: 100%; height: auto; color: #111; }
81
- .ms-info-panel dl { margin: 9px 0 20px; }
82
- .ms-info-panel dl div { padding: 8px 0; display: flex; justify-content: space-between; gap: 10px; border-bottom: 1px solid #232a34; }
83
- .ms-info-panel dt, .ms-info-panel dd { margin: 0; font-size: 9px; }
84
- .ms-info-panel dt { color: var(--muted); }
85
- .ms-info-panel dd { color: #dce1e9; }
86
- .ms-notation-info { margin-top: 20px; }
87
- .ms-downloads { margin-top: 22px; display: grid; gap: 7px; }
88
- .ms-downloads .ms-kicker { margin-bottom: 3px; }
89
- .ms-downloads a { padding: 9px 10px; border: 1px solid #323946; border-radius: 7px; color: #d5d9e1; background: #141a23; font-size: 9px; text-decoration: none; }
90
- .ms-downloads a[aria-disabled="true"] { opacity: .3; pointer-events: none; }
91
- .ms-downloads small { margin-top: 4px; color: #626c7a; font-size: 8px; line-height: 1.5; }
92
- .ms-announcer { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
93
- @container (max-width: 1100px) { .ms-grid { grid-template-columns: minmax(195px, 225px) minmax(0, 1fr); } .ms-info-panel { display: none; } .ms-synth-state { display: none; } }
94
- @container (max-width: 820px) { .ms-volume { display: none !important; } .ms-source-mix input { width: 56px; } }
95
- @container (max-width: 760px) { .ms-root { height: 920px; min-height: 920px; } .ms-statusbar { min-height: 66px; flex-basis: 66px; grid-template-columns: 1fr; gap: 8px; } .ms-song, .ms-progress { display: none; } .ms-transport { min-height: 94px; flex-basis: 94px; flex-wrap: wrap; } .ms-transport > input { min-width: calc(100% - 135px); } .ms-source-mix { margin-left: 86px; } .ms-grid { grid-template-columns: 1fr; grid-template-rows: 230px minmax(0, 1fr); } .ms-tracks-panel { border-right: 0; } .ms-track-list { grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); } .ms-main-panel { border-top: 1px solid var(--line); } .ms-score-view { padding: 10px; } }
96
- @container (max-width: 500px) { .ms-root { height: 940px; min-height: 940px; } .ms-statusbar { padding: 12px; } .ms-transport { padding: 8px; gap: 6px; } .ms-transport > span { min-width: 38px; font-size: 9px; } .ms-source-mix { margin-left: 0; } .ms-source-mix input { width: 42px; } .ms-roll-view { padding: 9px; } .ms-track-list { grid-template-columns: 1fr; } }
 
1
+ :root {
2
+ color-scheme: dark;
3
+ --ms-bg: #090b0f;
4
+ --ms-panel: #11151d;
5
+ --ms-line: rgba(255, 255, 255, .085);
6
+ --ms-line-strong: rgba(255, 255, 255, .15);
7
+ --ms-text: #f4f5f7;
8
+ --ms-soft: #b5bac6;
9
+ --ms-muted: #747b8c;
10
+ --ms-faint: #505766;
11
+ --ms-accent: #8b7cff;
12
+ --ms-success: #63d8a2;
13
+ --ms-warning: #f4bf75;
14
+ --ms-danger: #ff758e;
15
+ --ms-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
16
+ }
17
+
18
  * { box-sizing: border-box; }
19
+
20
+ .ms-root {
21
+ display: grid;
22
+ min-width: 0;
23
+ gap: 14px;
24
+ color: var(--ms-text);
25
+ font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
26
+ container-type: inline-size;
27
+ }
28
+
29
  .ms-root [hidden] { display: none !important; }
30
+ .ms-root button, .ms-root input, .ms-root select { font: inherit; }
31
+
32
+ .ms-topbar {
33
+ display: flex;
34
+ align-items: flex-start;
35
+ justify-content: space-between;
36
+ gap: 18px;
37
+ min-width: 0;
38
+ padding: 2px 2px 0;
39
+ }
40
+
41
+ .ms-heading { min-width: 0; }
42
+ .ms-heading h2 {
43
+ overflow: hidden;
44
+ max-width: 620px;
45
+ margin: 0;
46
+ color: var(--ms-text);
47
+ font-size: clamp(1.04rem, 2vw, 1.3rem);
48
+ font-weight: 620;
49
+ letter-spacing: -.032em;
50
+ text-overflow: ellipsis;
51
+ white-space: nowrap;
52
+ }
53
+ .ms-subtitle {
54
+ display: flex;
55
+ align-items: center;
56
+ gap: 8px;
57
+ margin: 6px 0 0;
58
+ color: var(--ms-muted);
59
+ font-size: .74rem;
60
+ line-height: 1.45;
61
+ }
62
+ .ms-subtitle b { color: var(--ms-faint); font: 560 .57rem var(--ms-mono); white-space: nowrap; }
63
+ .ms-subtitle b::before { margin-right: 8px; content: "·"; }
64
+
65
+ .ms-eyebrow {
66
+ display: flex;
67
+ align-items: center;
68
+ gap: 8px;
69
+ margin: 0 0 7px;
70
+ color: var(--ms-muted);
71
+ font: 650 .62rem/1 var(--ms-mono);
72
+ letter-spacing: .09em;
73
+ text-transform: uppercase;
74
+ }
75
+
76
+ .ms-state-dot {
77
+ width: 7px;
78
+ height: 7px;
79
+ flex: none;
80
+ border-radius: 50%;
81
+ background: var(--ms-faint);
82
+ box-shadow: 0 0 0 3px rgba(255,255,255,.035);
83
+ }
84
+ .ms-root[data-state="queued"] .ms-state-dot,
85
+ .ms-root[data-state="loading"] .ms-state-dot,
86
+ .ms-root[data-state="transcribing"] .ms-state-dot {
87
+ background: var(--ms-warning);
88
+ box-shadow: 0 0 0 4px rgba(244,191,117,.1), 0 0 14px rgba(244,191,117,.38);
89
+ animation: ms-pulse 1.7s ease-in-out infinite;
90
+ }
91
+ .ms-root[data-state="complete"] .ms-state-dot,
92
+ .ms-root[data-state="ready"] .ms-state-dot {
93
+ background: var(--ms-success);
94
+ box-shadow: 0 0 0 4px rgba(99,216,162,.09), 0 0 13px rgba(99,216,162,.32);
95
+ }
96
+ .ms-root[data-state="error"] .ms-state-dot,
97
+ .ms-root[data-state="notation_error"] .ms-state-dot { background: var(--ms-danger); }
98
+
99
+ .ms-quick-downloads { display: flex; flex: none; gap: 6px; }
100
+ .ms-quick-downloads a,
101
+ .ms-tracks-heading button,
102
+ .ms-track-controls button,
103
+ .ms-track-controls a {
104
+ display: inline-flex;
105
+ align-items: center;
106
+ justify-content: center;
107
+ border: 1px solid var(--ms-line);
108
+ border-radius: 9px;
109
+ background: rgba(255,255,255,.035);
110
+ color: var(--ms-soft);
111
+ cursor: pointer;
112
+ text-decoration: none;
113
+ transition: color 140ms ease, border-color 140ms ease, background 140ms ease, transform 140ms ease;
114
+ }
115
+ .ms-quick-downloads a { min-height: 33px; padding: 0 10px; font-size: .66rem; font-weight: 620; }
116
+ .ms-quick-downloads a:hover:not([aria-disabled="true"]),
117
+ .ms-tracks-heading button:hover,
118
+ .ms-track-controls button:hover,
119
+ .ms-track-controls a:hover:not([aria-disabled="true"]) {
120
+ border-color: rgba(139,124,255,.38);
121
+ background: rgba(139,124,255,.13);
122
+ color: #e4e1ff;
123
+ transform: translateY(-1px);
124
+ }
125
+ .ms-quick-downloads a[aria-disabled="true"], .ms-track-controls a[aria-disabled="true"] {
126
+ pointer-events: none;
127
+ opacity: .3;
128
+ }
129
+
130
+ .ms-progress {
131
+ display: grid;
132
+ gap: 8px;
133
+ padding: 11px 13px;
134
+ border: 1px solid var(--ms-line);
135
+ border-radius: 11px;
136
+ background: rgba(7,9,13,.45);
137
+ }
138
+ .ms-progress > div { display: flex; justify-content: space-between; gap: 12px; color: var(--ms-soft); font-size: .7rem; }
139
+ .ms-progress b { color: var(--ms-muted); font: 600 .64rem var(--ms-mono); }
140
+ .ms-progress i { display: block; overflow: hidden; height: 4px; border-radius: 99px; background: rgba(255,255,255,.055); }
141
+ .ms-progress i span { display: block; width: 0; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--ms-accent), #6b9bff); box-shadow: 0 0 18px rgba(119,102,255,.45); transition: width 320ms ease; }
142
+
143
+ .ms-empty {
144
+ min-height: 420px;
145
+ display: grid;
146
+ align-content: center;
147
+ justify-items: center;
148
+ padding: 34px 20px;
149
+ border: 1px solid var(--ms-line);
150
+ border-radius: 14px;
151
+ background: radial-gradient(circle at 50% 42%, rgba(139,124,255,.06), transparent 36%), #0a0d12;
152
+ text-align: center;
153
+ }
154
+ .ms-empty-icon { display: grid; width: 44px; height: 44px; border: 1px solid var(--ms-line); border-radius: 12px; background: rgba(255,255,255,.025); color: #aaa1ff; font-size: 20px; place-items: center; }
155
+ .ms-empty h3 { margin: 14px 0 6px; color: var(--ms-soft); font-size: .88rem; }
156
+ .ms-empty p { max-width: 430px; margin: 0; color: var(--ms-faint); font-size: .7rem; line-height: 1.6; }
157
+
158
+ .ms-workspace { display: grid; min-width: 0; gap: 12px; }
159
+
160
+ .ms-stats {
161
+ display: grid;
162
+ grid-template-columns: repeat(4, minmax(0,1fr));
163
+ overflow: hidden;
164
+ border: 1px solid var(--ms-line);
165
+ border-radius: 11px;
166
+ background: rgba(7,9,13,.36);
167
+ }
168
+ .ms-stats > div { min-width: 0; padding: 10px 12px; }
169
+ .ms-stats > div + div { border-left: 1px solid var(--ms-line); }
170
+ .ms-stats span { display: block; margin-bottom: 4px; color: var(--ms-faint); font: 650 .55rem var(--ms-mono); letter-spacing: .07em; text-transform: uppercase; }
171
+ .ms-stats strong { display: block; overflow: hidden; color: var(--ms-soft); font-size: .75rem; font-weight: 590; text-overflow: ellipsis; white-space: nowrap; }
172
+
173
+ .ms-transport {
174
+ display: grid;
175
+ gap: 9px;
176
+ padding: 10px;
177
+ border: 1px solid var(--ms-line);
178
+ border-radius: 12px;
179
+ background: rgba(7,9,13,.46);
180
+ }
181
+ .ms-transport-main, .ms-transport-options { display: flex; align-items: center; min-width: 0; gap: 8px; }
182
+ .ms-play, .ms-stop {
183
+ display: grid;
184
+ width: 34px;
185
+ height: 34px;
186
+ flex: none;
187
+ border: 1px solid var(--ms-line-strong);
188
+ border-radius: 50%;
189
+ color: #fff;
190
+ background: linear-gradient(135deg, #7a69eb, #5576d7);
191
+ cursor: pointer;
192
+ place-items: center;
193
+ }
194
+ .ms-stop { width: 29px; height: 29px; border-radius: 8px; color: var(--ms-soft); background: rgba(255,255,255,.04); }
195
+ .ms-play:disabled, .ms-stop:disabled { cursor: not-allowed; filter: saturate(.3); opacity: .38; }
196
+ .ms-transport-main > span { min-width: 44px; color: var(--ms-muted); font: 600 .62rem var(--ms-mono); text-align: center; }
197
+ .ms-seek { min-width: 80px; flex: 1; accent-color: var(--ms-accent); }
198
+ .ms-transport-options { flex-wrap: wrap; padding-left: 71px; }
199
+
200
+ .ms-source-mix { display: flex; align-items: center; gap: 5px; min-height: 32px; padding: 3px; border: 1px solid var(--ms-line); border-radius: 9px; background: rgba(255,255,255,.025); }
201
+ .ms-source-mix button { height: 24px; padding: 0 7px; border: 0; border-radius: 6px; color: var(--ms-muted); background: transparent; cursor: pointer; font-size: .58rem; font-weight: 720; }
202
+ .ms-source-mix button[aria-pressed="true"] { color: #fff; background: rgba(139,124,255,.32); }
203
+ .ms-source-mix input { width: 82px; accent-color: var(--ms-accent); }
204
+ .ms-stereo-toggle, .ms-volume { display: flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 8px; border: 1px solid var(--ms-line); border-radius: 9px; color: var(--ms-muted); background: rgba(255,255,255,.025); font-size: .6rem; white-space: nowrap; }
205
+ .ms-stereo-toggle { cursor: pointer; }
206
+ .ms-stereo-toggle input { accent-color: var(--ms-accent); }
207
+ .ms-stereo-toggle[data-active="true"] { border-color: rgba(139,124,255,.46); color: #dcd7ff; background: rgba(139,124,255,.12); }
208
+ .ms-volume input { width: 64px; accent-color: var(--ms-accent); }
209
+ .ms-synth-state { margin-left: auto; color: var(--ms-faint); font: 570 .56rem var(--ms-mono); }
210
+ .ms-synth-state[data-kind="loading"] { color: var(--ms-warning); }
211
+ .ms-synth-state[data-kind="ready"] { color: var(--ms-success); }
212
+ .ms-synth-state[data-kind="error"] { color: var(--ms-danger); }
213
+
214
+ .ms-view-shell { overflow: hidden; min-width: 0; border: 1px solid var(--ms-line); border-radius: 14px; background: #0a0d12; }
215
+ .ms-tabs { display: flex; align-items: center; min-height: 42px; gap: 20px; padding: 0 13px; border-bottom: 1px solid var(--ms-line); background: rgba(255,255,255,.018); }
216
+ .ms-tabs button { align-self: stretch; padding: 0 1px; border: 0; border-bottom: 2px solid transparent; color: var(--ms-muted); background: transparent; cursor: pointer; font-size: .64rem; font-weight: 720; text-transform: uppercase; }
217
+ .ms-tabs button[aria-selected="true"] { border-bottom-color: var(--ms-accent); color: var(--ms-text); }
218
+ .ms-tabs > span { overflow: hidden; margin-left: auto; color: var(--ms-faint); font: .57rem var(--ms-mono); text-overflow: ellipsis; white-space: nowrap; }
219
+ .ms-tabs label { display: flex; align-items: center; gap: 5px; color: var(--ms-faint); font-size: .58rem; }
220
+ .ms-tabs input { width: 62px; accent-color: var(--ms-accent); }
221
+
222
+ .ms-roll-view { height: 420px; min-width: 0; padding: 11px; }
223
+ .ms-roll-view canvas { display: block; width: 100%; height: 370px; border-radius: 8px; background: #090c11; cursor: crosshair; }
224
+ .ms-roll-view p { margin: 7px 1px 0; color: var(--ms-faint); font-size: .58rem; text-align: right; }
225
+ .ms-score-view { height: 420px; overflow: auto; padding: 14px; background: #11151b; overscroll-behavior: contain; }
226
+ .ms-score-empty { min-height: 100%; display: grid; align-content: center; justify-items: center; color: var(--ms-muted); text-align: center; }
227
+ .ms-score-empty span { color: var(--ms-accent); font-size: 36px; }
228
+ .ms-score-empty strong { margin-top: 9px; color: var(--ms-soft); font-size: .82rem; }
229
+ .ms-score-empty p { max-width: 420px; margin: 5px 0 0; font-size: .66rem; line-height: 1.5; }
230
+ .ms-score-paper { min-width: 720px; padding: 18px; border-radius: 4px; background: #fffdf7; box-shadow: 0 12px 36px rgba(0,0,0,.3); }
231
+ .ms-score-paper svg { display: block; width: 100%; height: auto; color: #111; }
232
+
233
+ .ms-notation-info { display: flex; align-items: center; gap: 12px; padding: 9px 11px; border: 1px solid var(--ms-line); border-radius: 10px; background: rgba(255,255,255,.022); }
234
+ .ms-notation-info > span { color: #aaa1ff; font: 650 .58rem var(--ms-mono); letter-spacing: .08em; text-transform: uppercase; }
235
+ .ms-notation-info dl { display: flex; flex: 1; gap: 14px; margin: 0; }
236
+ .ms-notation-info dl div { display: flex; gap: 5px; }
237
+ .ms-notation-info dt, .ms-notation-info dd { margin: 0; font-size: .58rem; }
238
+ .ms-notation-info dt { color: var(--ms-faint); }
239
+ .ms-notation-info dd { color: var(--ms-soft); }
240
+
241
+ .ms-tracks-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 4px 2px 0; }
242
+ .ms-tracks-heading strong, .ms-kicker { display: block; }
243
+ .ms-kicker { color: var(--ms-soft); font-size: .72rem; font-weight: 620; }
244
+ .ms-tracks-heading strong { margin-top: 3px; color: var(--ms-faint); font: .58rem var(--ms-mono); }
245
+ .ms-tracks-heading button { min-height: 29px; padding: 0 9px; font-size: .59rem; }
246
+ .ms-track-list { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; }
247
+ .ms-track-placeholder { grid-column: 1 / -1; padding: 15px; border: 1px dashed var(--ms-line); border-radius: 11px; color: var(--ms-faint); font-size: .68rem; text-align: center; }
248
+ .ms-track-row { display: grid; grid-template-columns: 6px minmax(0,1fr) auto; align-items: center; min-width: 0; min-height: 68px; gap: 10px; padding: 10px 11px; border: 1px solid var(--ms-line); border-radius: 11px; background: rgba(255,255,255,.022); transition: opacity 150ms ease, border-color 150ms ease, background 150ms ease; }
249
+ .ms-track-row:hover { border-color: var(--ms-line-strong); background: rgba(255,255,255,.034); }
250
+ .ms-track-row[data-inactive="true"] { opacity: .48; }
251
+ .ms-track-color { width: 6px; height: 36px; border-radius: 99px; background: var(--track); box-shadow: 0 0 15px color-mix(in srgb, var(--track) 35%, transparent); }
252
+ .ms-track-copy { min-width: 0; overflow: hidden; }
253
  .ms-track-copy strong, .ms-track-copy span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
254
+ .ms-track-copy strong { color: var(--ms-soft); font-size: .74rem; font-weight: 600; }
255
+ .ms-track-copy span { margin-top: 4px; color: var(--ms-faint); font: .56rem var(--ms-mono); }
256
+ .ms-program-select { width: min(100%, 158px); margin-top: 6px; padding: 3px 20px 3px 5px; border: 1px solid var(--ms-line); border-radius: 6px; background: #111721; color: var(--ms-soft); font-size: .57rem; }
257
  .ms-track-controls { display: flex; gap: 4px; }
258
+ .ms-track-controls button, .ms-track-controls a { width: 27px; height: 27px; padding: 0; font: 700 .58rem var(--ms-mono); }
259
+ .ms-track-controls button[aria-pressed="true"] { border-color: color-mix(in srgb, var(--track) 52%, transparent); background: color-mix(in srgb, var(--track) 18%, transparent); color: #fff; }
260
+
261
+ .ms-announcer { position: absolute; overflow: hidden; width: 1px; height: 1px; clip: rect(0 0 0 0); }
262
+
263
+ @keyframes ms-pulse { 0%,100% { opacity:.62; transform:scale(.88); } 50% { opacity:1; transform:scale(1); } }
264
+
265
+ @container (max-width: 760px) {
266
+ .ms-topbar { display: grid; }
267
+ .ms-quick-downloads { width: 100%; }
268
+ .ms-quick-downloads a { flex: 1; }
269
+ .ms-transport-options { padding-left: 0; }
270
+ .ms-synth-state { width: 100%; margin-left: 0; }
271
+ .ms-track-list { grid-template-columns: 1fr; }
272
+ }
273
+
274
+ @container (max-width: 520px) {
275
+ .ms-stats { grid-template-columns: repeat(2,minmax(0,1fr)); }
276
+ .ms-stats > div:nth-child(3) { border-top: 1px solid var(--ms-line); border-left: 0; }
277
+ .ms-stats > div:nth-child(4) { border-top: 1px solid var(--ms-line); }
278
+ .ms-transport-main { flex-wrap: wrap; }
279
+ .ms-seek { min-width: calc(100% - 134px); }
280
+ .ms-source-mix { width: 100%; }
281
+ .ms-source-mix input { flex: 1; }
282
+ .ms-tabs > span { display: none; }
283
+ .ms-roll-view, .ms-score-view { height: 330px; }
284
+ .ms-roll-view canvas { height: 282px; }
285
+ .ms-notation-info { align-items: flex-start; }
286
+ .ms-notation-info dl { display: grid; grid-template-columns: repeat(2,1fr); gap: 6px 12px; }
287
+ }
288
+
289
+ @media (prefers-reduced-motion: reduce) {
290
+ .ms-root *, .ms-root *::before, .ms-root *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
291
+ }
 
 
frontend/studio.html CHANGED
@@ -1,87 +1,95 @@
1
- <main class="ms-root" data-state="idle">
2
- <section class="ms-statusbar">
3
- <div class="ms-status-copy">
4
- <span class="ms-state-dot"></span>
5
- <div><strong data-field="state">Prêt</strong><span data-field="status">Importez un enregistrement pour commencer.</span></div>
 
6
  </div>
7
- <div class="ms-song"><strong data-field="audio-name">Aucun morceau</strong><span data-field="summary">—</span></div>
8
- <div class="ms-progress" hidden>
9
- <div><span data-field="progress-label">Préparation</span><b data-field="progress-value">0%</b></div>
10
- <i><span data-field="progress-bar"></span></i>
11
  </div>
12
- </section>
 
 
 
 
 
13
 
14
  <section class="ms-empty">
15
- <div class="ms-empty-icon">♫</div>
16
- <h2>La transcription apparaîtra ici</h2>
17
- <p>Le piano roll et les pistes MIDI apparaîtront après la transcription. La partition pourra ensuite être générée à la demande.</p>
18
  </section>
19
 
20
  <section class="ms-workspace" hidden>
21
- <div class="ms-transport">
22
- <button type="button" data-action="play" class="ms-play" aria-label="Lecture"></button>
23
- <button type="button" data-action="stop" class="ms-icon-button" aria-label="Retour au début"></button>
24
- <span data-field="clock">0:00.0</span>
25
- <input data-action="seek" type="range" min="0" max="1" step="0.01" value="0" aria-label="Position">
26
- <span data-field="duration">0:00.0</span>
27
- <div class="ms-source-mix" title="Fondu entre l’enregistrement original et la transcription MIDI">
28
- <button type="button" data-source="original">Original</button>
29
- <input data-action="source-mix" type="range" min="0" max="1" step="0.01" value="0.55" aria-label="Mix original et MIDI">
30
- <button type="button" data-source="midi">MIDI</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  </div>
32
- <label class="ms-stereo-toggle" title="Place l’original à gauche et le MIDI à droite"><input data-action="stereo" type="checkbox"> L/R</label>
33
- <label class="ms-volume">Volume <input data-action="volume" type="range" min="0" max="1" step="0.01" value="0.65"></label>
34
- <span data-field="synth-state" class="ms-synth-state">SoundFont au premier play</span>
35
  </div>
36
 
37
- <div class="ms-grid">
38
- <aside class="ms-tracks-panel">
39
- <header><div><span class="ms-kicker">PISTES</span><strong data-field="track-count">0 piste</strong></div><button type="button" data-action="reset-mix">Tout</button></header>
40
- <div data-region="tracks" class="ms-track-list"></div>
41
- </aside>
 
 
42
 
43
- <section class="ms-main-panel">
44
- <nav class="ms-tabs" aria-label="Visualisation">
45
- <button type="button" data-tab="roll" aria-selected="true">Piano roll</button>
46
- <button type="button" data-tab="score" aria-selected="false">Partition</button>
47
- </nav>
48
- <div data-view="roll" class="ms-roll-view">
49
- <div class="ms-roll-toolbar"><span data-field="pitch-range">—</span><label>Zoom <input data-action="zoom" type="range" min="1" max="8" step="1" value="1"></label></div>
50
- <canvas data-canvas="roll" height="470" aria-label="Piano roll multipiste"></canvas>
51
- <p>Cliquez dans le piano roll pour déplacer la tête de lecture.</p>
52
- </div>
53
- <div data-view="score" class="ms-score-view" hidden>
54
- <div data-region="score-empty" class="ms-score-empty"><span>♩</span><strong>Aucune partition générée</strong><p>Après la transcription, utilisez le bouton « Generate score » dans les paramètres.</p></div>
55
- <div data-region="score" class="ms-score-paper"></div>
56
- </div>
57
- </section>
58
 
59
- <aside class="ms-info-panel">
60
- <span class="ms-kicker">SESSION</span>
61
- <dl>
62
- <div><dt>Durée</dt><dd data-field="info-duration">—</dd></div>
63
- <div><dt>Notes</dt><dd data-field="note-count">—</dd></div>
64
- <div><dt>Pistes</dt><dd data-field="info-tracks">—</dd></div>
65
- <div><dt>Calcul</dt><dd data-field="elapsed">—</dd></div>
66
- </dl>
67
- <div class="ms-notation-info" hidden>
68
- <span class="ms-kicker">PARTITION</span>
69
- <dl>
70
- <div><dt>Tempo</dt><dd data-field="tempo">—</dd></div>
71
- <div><dt>Mesure</dt><dd data-field="meter">—</dd></div>
72
- <div><dt>Grille</dt><dd data-field="quantization">—</dd></div>
73
- <div><dt>Pages</dt><dd data-field="pages">—</dd></div>
74
- </dl>
75
- </div>
76
- <div class="ms-downloads">
77
- <span class="ms-kicker">EXPORT RAPIDE</span>
78
- <a data-download="midi" aria-disabled="true">MIDI complet</a>
79
- <a data-download="musicxml" aria-disabled="true">MusicXML</a>
80
- <a data-download="svg" aria-disabled="true">SVG de la partition</a>
81
- <small>Les exports PDF, pistes séparées et ZIP sont disponibles sous le visualiseur.</small>
82
  </div>
83
- </aside>
 
 
 
 
 
 
 
 
 
 
 
84
  </div>
 
 
 
 
 
 
85
  </section>
 
86
  <div class="ms-announcer" aria-live="polite"></div>
87
- </main>
 
1
+ <section class="ms-root" data-state="idle" aria-label="MuScriptor transcription studio">
2
+ <header class="ms-topbar">
3
+ <div class="ms-heading">
4
+ <p class="ms-eyebrow"><span class="ms-state-dot" aria-hidden="true"></span><span data-field="state">Ready for audio</span></p>
5
+ <h2 data-field="audio-name">Your transcription</h2>
6
+ <p class="ms-subtitle"><span data-field="status">Upload a recording to reveal its notes and instruments.</span><b data-field="summary">—</b></p>
7
  </div>
8
+ <div class="ms-quick-downloads" aria-label="Quick exports">
9
+ <a data-download="midi" aria-disabled="true">MIDI</a>
10
+ <a data-download="musicxml" aria-disabled="true">MusicXML</a>
11
+ <a data-download="svg" aria-disabled="true">Score SVG</a>
12
  </div>
13
+ </header>
14
+
15
+ <div class="ms-progress" hidden>
16
+ <div><span data-field="progress-label">Preparing inference…</span><b data-field="progress-value">0%</b></div>
17
+ <i role="progressbar" aria-valuemin="0" aria-valuemax="100"><span data-field="progress-bar"></span></i>
18
+ </div>
19
 
20
  <section class="ms-empty">
21
+ <div class="ms-empty-icon" aria-hidden="true">♫</div>
22
+ <h3>A piano roll will appear here</h3>
23
+ <p>MuScriptor separates notes into color-coded instrument tracks. Generate an editable score after transcription when you need one.</p>
24
  </section>
25
 
26
  <section class="ms-workspace" hidden>
27
+ <div class="ms-stats" aria-label="Transcription statistics">
28
+ <div><span>Duration</span><strong data-field="info-duration"></strong></div>
29
+ <div><span>Notes</span><strong data-field="note-count"></strong></div>
30
+ <div><span>Tracks</span><strong data-field="info-tracks"></strong></div>
31
+ <div><span>Elapsed</span><strong data-field="elapsed">—</strong></div>
32
+ </div>
33
+
34
+ <div class="ms-transport" aria-label="Playback controls">
35
+ <div class="ms-transport-main">
36
+ <button type="button" data-action="play" class="ms-play" aria-label="Play" disabled></button>
37
+ <button type="button" data-action="stop" class="ms-stop" aria-label="Return to start" disabled>■</button>
38
+ <span data-field="clock">0:00.0</span>
39
+ <input data-action="seek" class="ms-seek" type="range" min="0" max="1" step="0.01" value="0" aria-label="Playback position">
40
+ <span data-field="duration">0:00.0</span>
41
+ </div>
42
+ <div class="ms-transport-options">
43
+ <div class="ms-source-mix" title="Blend the original recording and MIDI transcription">
44
+ <button type="button" data-source="original">Original</button>
45
+ <input data-action="source-mix" type="range" min="0" max="1" step="0.01" value="0.5" aria-label="Original to MIDI mix">
46
+ <button type="button" data-source="midi">MIDI</button>
47
+ </div>
48
+ <label class="ms-stereo-toggle" title="Original on the left, MIDI on the right"><input data-action="stereo" type="checkbox"> Split L/R</label>
49
+ <label class="ms-volume">Volume <input data-action="volume" type="range" min="0" max="1" step="0.01" value="0.72"></label>
50
+ <span data-field="synth-state" class="ms-synth-state">SoundFont loads on first play</span>
51
  </div>
 
 
 
52
  </div>
53
 
54
+ <section class="ms-view-shell">
55
+ <nav class="ms-tabs" aria-label="Visualization">
56
+ <button type="button" data-tab="roll" aria-selected="true">Piano roll</button>
57
+ <button type="button" data-tab="score" aria-selected="false">Score</button>
58
+ <span data-field="pitch-range">Waiting for notes</span>
59
+ <label>Zoom <input data-action="zoom" type="range" min="1" max="8" step="1" value="1"></label>
60
+ </nav>
61
 
62
+ <div data-view="roll" class="ms-roll-view">
63
+ <canvas data-canvas="roll" height="410" aria-label="Color-coded multitrack piano roll"></canvas>
64
+ <p>Click the piano roll to seek. Use zoom to inspect dense passages.</p>
65
+ </div>
 
 
 
 
 
 
 
 
 
 
 
66
 
67
+ <div data-view="score" class="ms-score-view" hidden>
68
+ <div data-region="score-empty" class="ms-score-empty">
69
+ <span aria-hidden="true">♩</span>
70
+ <strong>No score generated yet</strong>
71
+ <p>After transcription, open “Create an editable score” below and generate it manually.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  </div>
73
+ <div data-region="score" class="ms-score-paper" hidden></div>
74
+ </div>
75
+ </section>
76
+
77
+ <div class="ms-notation-info" hidden>
78
+ <span>Score</span>
79
+ <dl>
80
+ <div><dt>Tempo</dt><dd data-field="tempo">—</dd></div>
81
+ <div><dt>Time</dt><dd data-field="meter">—</dd></div>
82
+ <div><dt>Grid</dt><dd data-field="quantization">—</dd></div>
83
+ <div><dt>Pages</dt><dd data-field="pages">—</dd></div>
84
+ </dl>
85
  </div>
86
+
87
+ <div class="ms-tracks-heading">
88
+ <div><span class="ms-kicker">Instrument tracks</span><strong data-field="track-count">No tracks yet</strong></div>
89
+ <button type="button" data-action="reset-mix">Reset mute &amp; solo</button>
90
+ </div>
91
+ <div data-region="tracks" class="ms-track-list" aria-label="Instrument tracks"></div>
92
  </section>
93
+
94
  <div class="ms-announcer" aria-live="polite"></div>
95
+ </section>
frontend/studio.js CHANGED
@@ -20,7 +20,8 @@ const ui = {
20
  const state = {
21
  data: null, audio: null, master: null, compressor: null, gains: new Map(),
22
  midiGain: null, midiPanner: null, originalGain: null, originalPanner: null,
23
- originalElement: null, originalSource: null, originalSources: new WeakMap(), originalFallback: false,
 
24
  sourceMix: Number(ui.sourceMix.value), stereo: Boolean(ui.stereo.checked),
25
  synth: null, synthPromise: null, synthFailed: false, channels: new Map(), nextChannel: 0,
26
  programOverrides: new Map(),
@@ -35,7 +36,7 @@ const formatTime = (seconds) => {
35
  const minutes = Math.floor(safe / 60);
36
  return `${minutes}:${(safe % 60).toFixed(1).padStart(4, '0')}`;
37
  };
38
- const noteName = (pitch) => `${['Do','Do♯','','Mi♭','Mi','Fa','Fa♯','Sol','La♭','La','Si♭','Si'][pitch % 12]}${Math.floor(pitch / 12) - 1}`;
39
  const setText = (selector, value) => { const target = $(selector); if (target) target.textContent = value; };
40
 
41
  function normalize(value) {
@@ -94,21 +95,21 @@ function setSynthStatus(label, kind = '') {
94
  async function initSoundFont() {
95
  const library = globalThis.MuScriptorSynthLib;
96
  if (!library?.WorkletSynthesizer || !library?.processorUrl) {
97
- throw new Error('SpessaSynth indisponible');
98
  }
99
- setSynthStatus('Chargement du moteur…', 'loading');
100
  await state.audio.audioWorklet.addModule(library.processorUrl);
101
  const synth = new library.WorkletSynthesizer(state.audio);
102
  synth.connect(state.midiGain);
103
  await synth.isReady;
104
- setSynthStatus('SoundFont 38 Mo…', 'loading');
105
  const response = await fetch(SOUNDFONT_URL);
106
  if (!response.ok) throw new Error(`SoundFont HTTP ${response.status}`);
107
  await synth.soundBankManager.addSoundBank(await response.arrayBuffer(), 'MuseScore General');
108
  state.synth = synth;
109
  state.channels.clear();
110
  state.nextChannel = 0;
111
- setSynthStatus('MuseScore General prêt', 'ready');
112
  refreshMix();
113
  return synth;
114
  }
@@ -132,12 +133,11 @@ function ensureAudio() {
132
  state.originalGain.connect(state.originalPanner).connect(state.master);
133
  state.master.connect(state.compressor).connect(state.audio.destination);
134
  rebuildGains();
135
- setupOriginalAudio();
136
  applySourceMix();
137
  state.synthPromise = initSoundFont().catch((error) => {
138
  state.synthFailed = true;
139
- console.warn('SoundFont indisponible, fallback WebAudio:', error);
140
- setSynthStatus('Synthèse légère (fallback)', 'error');
141
  return null;
142
  });
143
  }
@@ -164,48 +164,101 @@ function findOriginalAudio() {
164
  return null;
165
  }
166
 
167
- function setupOriginalAudio() {
168
- if (!state.audio || !state.originalGain) return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  const audio = findOriginalAudio();
170
- if (!audio) { applySourceMix(); return null; }
171
- if (audio === state.originalElement) { applySourceMix(); return audio; }
172
- if (state.originalElement && !state.originalElement.paused) state.originalElement.pause();
173
- if (state.originalSource) { try { state.originalSource.disconnect(); } catch (_) {} }
 
 
 
 
 
 
174
  state.originalElement = audio;
175
- state.originalFallback = false;
176
- try {
177
- let source = state.originalSources.get(audio);
178
- if (!source) {
179
- source = state.audio.createMediaElementSource(audio);
180
- state.originalSources.set(audio, source);
181
- }
182
- try { source.disconnect(); } catch (_) {}
183
- source.connect(state.originalGain);
184
- state.originalSource = source;
185
- audio.volume = 1;
186
- } catch (error) {
187
- console.warn('Routage WebAudio de la source originale indisponible:', error);
188
- state.originalSource = null;
189
- state.originalFallback = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  }
191
- audio.preload = 'auto';
192
- applySourceMix();
193
- return audio;
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  }
195
 
196
  function applySourceMix() {
197
  const mix = clamp(Number(state.sourceMix), 0, 1);
198
- const hasOriginal = Boolean(state.originalElement);
199
- const originalLevel = hasOriginal ? Math.cos(mix * Math.PI / 2) : 0;
200
- const midiLevel = hasOriginal ? Math.sin(mix * Math.PI / 2) : 1;
 
 
201
  if (state.audio) {
202
  const now = state.audio.currentTime;
203
- state.originalGain?.gain.setTargetAtTime(state.originalFallback ? 0 : Math.max(.0001, originalLevel), now, .018);
204
  state.midiGain?.gain.setTargetAtTime(Math.max(.0001, midiLevel), now, .018);
205
  state.originalPanner?.pan.setTargetAtTime(state.stereo && hasOriginal ? -1 : 0, now, .018);
206
  state.midiPanner?.pan.setTargetAtTime(state.stereo && hasOriginal ? 1 : 0, now, .018);
207
  }
208
- if (state.originalElement && state.originalFallback) state.originalElement.volume = originalLevel;
209
  $$('[data-source]').forEach((button) => {
210
  const selected = button.dataset.source === 'original' ? mix <= .01 : mix >= .99;
211
  button.setAttribute('aria-pressed', String(selected));
@@ -254,18 +307,19 @@ function waveform(track) {
254
  }
255
 
256
  function scheduleNote(track, note, nowSong) {
 
 
 
 
257
  if (state.synth) {
258
  const channel = channelFor(track);
259
- const startAt = state.audio.currentTime + Math.max(.006, note.start - nowSong);
260
- const endAt = startAt + clamp(note.end - Math.max(note.start, nowSong), .03, 12);
261
  state.synth.noteOn(channel, note.pitch, Math.round(note.velocity), { time: startAt });
262
- state.synth.noteOff(channel, note.pitch, { time: endAt });
263
  return;
264
  }
265
  const gain = state.gains.get(track.id);
266
  if (!state.audio || !gain) return;
267
- const startAt = state.audio.currentTime + Math.max(.006, note.start - nowSong);
268
- const duration = clamp(note.end - Math.max(note.start, nowSong), .03, 8);
269
  const oscillator = state.audio.createOscillator();
270
  const envelope = state.audio.createGain();
271
  oscillator.type = waveform(track);
@@ -279,10 +333,12 @@ function scheduleNote(track, note, nowSong) {
279
  }
280
 
281
  function exactTime() {
282
- if (state.playing && state.originalElement && !state.originalElement.paused && Number.isFinite(state.originalElement.currentTime)) {
283
- return state.originalElement.currentTime;
284
- }
285
- return state.playing && state.audio ? state.originSong + state.audio.currentTime - state.originAudio : state.songTime;
 
 
286
  }
287
 
288
  function lowerBound(time) {
@@ -294,14 +350,11 @@ function lowerBound(time) {
294
  function stopPlayback(reset = false) {
295
  if (state.playing) state.songTime = clamp(exactTime(), 0, state.data?.duration || 0);
296
  state.playing = false; cancelAnimationFrame(state.animation);
297
- if (state.originalElement && !state.originalElement.paused) state.originalElement.pause();
298
  state.nodes.forEach((node) => { try { node.stop(); } catch (_) {} }); state.nodes.clear();
299
  state.synth?.stopAll();
300
- if (reset) {
301
- state.songTime = 0;
302
- if (state.originalElement) { try { state.originalElement.currentTime = 0; } catch (_) {} }
303
- }
304
- ui.play.textContent = '▶'; ui.play.setAttribute('aria-label', 'Lecture');
305
  updateTimeline();
306
  }
307
 
@@ -309,20 +362,21 @@ async function startPlayback() {
309
  if (!state.data || !state.notes.length || state.playing) return;
310
  ensureAudio(); if (!state.audio) return;
311
  await state.audio.resume();
312
- if (state.synthPromise && !state.synthFailed) await state.synthPromise;
 
 
 
 
313
  if (state.songTime >= state.data.duration - .02) state.songTime = 0;
314
- const original = setupOriginalAudio();
315
- if (original) {
316
- try {
317
- original.currentTime = state.songTime;
318
- await original.play();
319
- } catch (error) {
320
- console.warn('Lecture de la source originale indisponible:', error);
321
- }
322
- }
323
- state.originSong = state.songTime; state.originAudio = state.audio.currentTime;
324
- state.nextNote = lowerBound(Math.max(0, state.songTime - .01)); state.playing = true;
325
- ui.play.textContent = '❚❚'; ui.play.setAttribute('aria-label', 'Pause'); frame();
326
  }
327
 
328
  function frame() {
@@ -338,7 +392,6 @@ function frame() {
338
 
339
  function seekTo(time) {
340
  const resume = state.playing; stopPlayback(false); state.songTime = clamp(time, 0, state.data.duration);
341
- if (state.originalElement) { try { state.originalElement.currentTime = state.songTime; } catch (_) {} }
342
  updateTimeline(); if (resume) startPlayback();
343
  }
344
 
@@ -349,15 +402,24 @@ function updateTimeline() {
349
 
350
  function renderTracks() {
351
  ui.tracks.replaceChildren();
 
 
 
 
 
 
 
 
 
352
  state.data.tracks.forEach((track) => {
353
  const row = document.createElement('article'); row.className = 'ms-track-row'; row.dataset.track = track.id; row.style.setProperty('--track', track.color);
354
  const dot = document.createElement('i'); dot.className = 'ms-track-color';
355
  const copy = document.createElement('div'); copy.className = 'ms-track-copy';
356
  const name = document.createElement('strong'); name.textContent = track.name;
357
- const meta = document.createElement('span'); meta.textContent = `${track.note_count.toLocaleString('fr-FR')} notes${track.is_drum ? ' · drums' : ''}`; copy.append(name, meta);
358
  if (!track.is_drum) {
359
  const select = document.createElement('select');
360
- select.className = 'ms-program-select'; select.dataset.program = track.id; select.setAttribute('aria-label', `Timbre de ${track.name}`);
361
  const selectedProgram = state.programOverrides.get(track.id) ?? track.program;
362
  GM_PROGRAMS.forEach(([program, label]) => {
363
  const option = document.createElement('option'); option.value = String(program); option.textContent = label; option.selected = program === selectedProgram; select.append(option);
@@ -369,22 +431,23 @@ function renderTracks() {
369
  }
370
  const controls = document.createElement('div'); controls.className = 'ms-track-controls';
371
  ['solo', 'mute'].forEach((kind) => { const button = document.createElement('button'); button.type = 'button'; button.dataset.mix = kind; button.textContent = kind === 'solo' ? 'S' : 'M'; button.title = kind === 'solo' ? 'Solo' : 'Mute'; button.setAttribute('aria-pressed', 'false'); controls.append(button); });
372
- const download = document.createElement('a'); download.textContent = '↓'; download.title = `Télécharger ${track.name}`;
373
  if (track.midi) { download.href = track.midi; download.download = `${track.key}.mid`; } else download.setAttribute('aria-disabled', 'true');
374
  controls.append(download); row.append(dot, copy, controls); ui.tracks.append(row);
375
  });
376
  }
377
 
378
  function drawRoll() {
379
- if (!state.data || !state.data.tracks.length || state.activeTab !== 'roll') return;
380
  const canvas = ui.canvas, rect = canvas.getBoundingClientRect(); if (!rect.width) return;
381
  const dpr = Math.min(window.devicePixelRatio || 1, 2), width = Math.round(rect.width * dpr), height = Math.round(rect.height * dpr);
382
  if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; }
383
  const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const w = rect.width, h = rect.height;
384
  ctx.clearRect(0, 0, w, h); ctx.fillStyle = '#090c11'; ctx.fillRect(0, 0, w, h);
385
  const allPitches = state.notes.map((item) => item.note.pitch); const minPitch = Math.min(...allPitches, 48) - 2, maxPitch = Math.max(...allPitches, 72) + 2, pitchSpan = maxPitch - minPitch + 1;
386
- const zoom = Number(ui.zoom.value), span = Math.max(.1, state.data.duration / zoom);
387
- const start = zoom === 1 ? 0 : clamp(state.songTime - span * .32, 0, Math.max(0, state.data.duration - span)); const end = Math.min(state.data.duration, start + span);
 
388
  state.viewStart = start; state.viewEnd = end;
389
  ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(255,255,255,.055)';
390
  for (let pitch = minPitch; pitch <= maxPitch; pitch++) { const y = h - ((pitch - minPitch + 1) / pitchSpan) * h; if ([1,3,6,8,10].includes(pitch % 12)) { ctx.fillStyle = 'rgba(255,255,255,.012)'; ctx.fillRect(0, y - h / pitchSpan, w, h / pitchSpan); } if (pitch % 12 === 0) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } }
@@ -406,6 +469,7 @@ function render(data) {
406
  if (state.playing && previousSignature !== nextSignature) stopPlayback(true);
407
  if (audioChanged) {
408
  stopPlayback(true);
 
409
  state.synth?.stopAll();
410
  state.channels.clear();
411
  state.nextChannel = 0;
@@ -415,15 +479,15 @@ function render(data) {
415
  root.dataset.state = data.state; const active = ['queued', 'loading', 'transcribing'].includes(data.state);
416
  const hasResults = data.tracks.length > 0 || active;
417
  ui.empty.hidden = hasResults; ui.workspace.hidden = !hasResults;
418
- ui.state.textContent = ({ idle: 'Prêt', transcribing: 'Transcription', complete: 'MIDI prêt', ready: 'Terminé', error: 'Erreur', notation_error: 'MIDI prêt' })[data.state] || data.state;
419
- ui.status.textContent = data.status; ui.audioName.textContent = data.audio_name || 'Aucun morceau';
420
- ui.summary.textContent = data.audio_name ? `${data.note_count.toLocaleString('fr-FR')} notes · ${data.tracks.length} pistes` : '—';
421
  ui.progress.hidden = !active; ui.progressLabel.textContent = data.status; ui.progressValue.textContent = `${Math.round(data.progress * 100)}%`; ui.progressBar.style.width = `${data.progress * 100}%`;
422
  ui.duration.textContent = formatTime(data.duration); ui.seek.max = String(Math.max(.01, data.duration));
423
- setText('[data-field="track-count"]', `${data.tracks.length} piste${data.tracks.length > 1 ? 's' : ''}`);
424
- setText('[data-field="info-duration"]', formatTime(data.duration)); setText('[data-field="note-count"]', data.note_count ? data.note_count.toLocaleString('fr-FR') : '—');
425
  setText('[data-field="info-tracks"]', data.tracks.length || '—'); setText('[data-field="elapsed"]', data.elapsed ? `${data.elapsed.toFixed(1)} s` : '—');
426
- const pitches = state.notes.map((item) => item.note.pitch); setText('[data-field="pitch-range"]', pitches.length ? `${noteName(Math.min(...pitches))} → ${noteName(Math.max(...pitches))}` : 'En attente de notes');
427
  state.muted.forEach((id) => { if (!data.tracks.some((track) => track.id === id)) state.muted.delete(id); }); state.solo.forEach((id) => { if (!data.tracks.some((track) => track.id === id)) state.solo.delete(id); });
428
  renderTracks(); rebuildGains();
429
  ui.score.replaceChildren();
@@ -435,7 +499,7 @@ function render(data) {
435
  const stem = (data.audio_name || 'muscriptor').replace(/\.[^.]+$/, '');
436
  updateDownload('[data-download="midi"]', data.full_midi, `${stem}.mid`); updateDownload('[data-download="musicxml"]', notation.musicxml || '', `${stem}.musicxml`); updateDownload('[data-download="svg"]', notation.svg || '', `${stem}.svg`);
437
  ui.play.disabled = !state.notes.length; ui.stop.disabled = !state.notes.length; updateTimeline();
438
- if (state.audio) setupOriginalAudio();
439
  applySourceMix();
440
  if (data.state === 'ready') switchTab('score');
441
  announce(`${ui.state.textContent}. ${data.status}`);
@@ -445,14 +509,14 @@ ui.play.addEventListener('click', () => {
445
  if (state.playing) stopPlayback(false);
446
  else startPlayback().catch((error) => {
447
  console.error(error);
448
- setSynthStatus('Lecture impossible', 'error');
449
  });
450
  });
451
  ui.stop.addEventListener('click', () => stopPlayback(true));
452
  ui.seek.addEventListener('input', () => state.data && seekTo(Number(ui.seek.value)));
453
  ui.volume.addEventListener('input', () => { if (state.master && state.audio) state.master.gain.setTargetAtTime(Number(ui.volume.value), state.audio.currentTime, .02); });
454
  ui.sourceMix.addEventListener('input', () => { state.sourceMix = Number(ui.sourceMix.value); applySourceMix(); });
455
- ui.stereo.addEventListener('change', () => { state.stereo = ui.stereo.checked; applySourceMix(); announce(state.stereo ? 'Mode stéréo : original à gauche, MIDI à droite.' : 'Mode stéréo désactivé.'); });
456
  $$('[data-source]').forEach((button) => button.addEventListener('click', () => {
457
  state.sourceMix = button.dataset.source === 'original' ? 0 : 1;
458
  ui.sourceMix.value = String(state.sourceMix);
@@ -473,7 +537,7 @@ ui.tracks.addEventListener('change', (event) => {
473
  state.synth.stopAll();
474
  state.synth.programChange(channel, program);
475
  }
476
- announce(`Timbre changé : ${select.options[select.selectedIndex].textContent}.`);
477
  });
478
  $$('[data-tab]').forEach((button) => button.addEventListener('click', () => switchTab(button.dataset.tab)));
479
  new ResizeObserver(() => drawRoll()).observe(ui.canvas);
 
20
  const state = {
21
  data: null, audio: null, master: null, compressor: null, gains: new Map(),
22
  midiGain: null, midiPanner: null, originalGain: null, originalPanner: null,
23
+ originalElement: null, originalUrl: '', originalBuffer: null,
24
+ originalBufferPromise: null, originalBufferSource: null, originalFailed: false,
25
  sourceMix: Number(ui.sourceMix.value), stereo: Boolean(ui.stereo.checked),
26
  synth: null, synthPromise: null, synthFailed: false, channels: new Map(), nextChannel: 0,
27
  programOverrides: new Map(),
 
36
  const minutes = Math.floor(safe / 60);
37
  return `${minutes}:${(safe % 60).toFixed(1).padStart(4, '0')}`;
38
  };
39
+ const noteName = (pitch) => `${['C','C♯','D','E♭','E','F','F♯','G','A♭','A','B♭','B'][pitch % 12]}${Math.floor(pitch / 12) - 1}`;
40
  const setText = (selector, value) => { const target = $(selector); if (target) target.textContent = value; };
41
 
42
  function normalize(value) {
 
95
  async function initSoundFont() {
96
  const library = globalThis.MuScriptorSynthLib;
97
  if (!library?.WorkletSynthesizer || !library?.processorUrl) {
98
+ throw new Error('SpessaSynth is unavailable');
99
  }
100
+ setSynthStatus('Loading audio engine…', 'loading');
101
  await state.audio.audioWorklet.addModule(library.processorUrl);
102
  const synth = new library.WorkletSynthesizer(state.audio);
103
  synth.connect(state.midiGain);
104
  await synth.isReady;
105
+ setSynthStatus('Loading 38 MB SoundFont…', 'loading');
106
  const response = await fetch(SOUNDFONT_URL);
107
  if (!response.ok) throw new Error(`SoundFont HTTP ${response.status}`);
108
  await synth.soundBankManager.addSoundBank(await response.arrayBuffer(), 'MuseScore General');
109
  state.synth = synth;
110
  state.channels.clear();
111
  state.nextChannel = 0;
112
+ setSynthStatus('MuseScore General ready', 'ready');
113
  refreshMix();
114
  return synth;
115
  }
 
133
  state.originalGain.connect(state.originalPanner).connect(state.master);
134
  state.master.connect(state.compressor).connect(state.audio.destination);
135
  rebuildGains();
 
136
  applySourceMix();
137
  state.synthPromise = initSoundFont().catch((error) => {
138
  state.synthFailed = true;
139
+ console.warn('SoundFont unavailable, using WebAudio fallback:', error);
140
+ setSynthStatus('Lightweight synth fallback', 'error');
141
  return null;
142
  });
143
  }
 
164
  return null;
165
  }
166
 
167
+ function stopOriginalBufferSource() {
168
+ if (!state.originalBufferSource) return;
169
+ try { state.originalBufferSource.stop(); } catch (_) {}
170
+ try { state.originalBufferSource.disconnect(); } catch (_) {}
171
+ state.originalBufferSource = null;
172
+ }
173
+
174
+ function resetOriginalAudio() {
175
+ stopOriginalBufferSource();
176
+ state.originalElement = null;
177
+ state.originalUrl = '';
178
+ state.originalBuffer = null;
179
+ state.originalBufferPromise = null;
180
+ state.originalFailed = false;
181
+ }
182
+
183
+ function discoverOriginalAudio() {
184
  const audio = findOriginalAudio();
185
+ if (!audio) return null;
186
+ const url = String(audio.currentSrc || audio.src || '');
187
+ if (!url) return null;
188
+ if (url !== state.originalUrl) {
189
+ stopOriginalBufferSource();
190
+ state.originalUrl = url;
191
+ state.originalBuffer = null;
192
+ state.originalBufferPromise = null;
193
+ state.originalFailed = false;
194
+ }
195
  state.originalElement = audio;
196
+ if (!audio.paused) audio.pause();
197
+ return url;
198
+ }
199
+
200
+ async function loadOriginalBuffer() {
201
+ if (!state.audio) return null;
202
+ const url = discoverOriginalAudio();
203
+ if (!url) return null;
204
+ if (state.originalBuffer) return state.originalBuffer;
205
+ if (!state.originalBufferPromise) {
206
+ const requestedUrl = url;
207
+ state.originalBufferPromise = fetch(url)
208
+ .then((response) => {
209
+ if (!response.ok) throw new Error(`Original audio HTTP ${response.status}`);
210
+ return response.arrayBuffer();
211
+ })
212
+ .then((data) => state.audio.decodeAudioData(data.slice(0)))
213
+ .then((buffer) => {
214
+ if (state.originalUrl !== requestedUrl) return null;
215
+ state.originalBuffer = buffer;
216
+ state.originalFailed = false;
217
+ applySourceMix();
218
+ return buffer;
219
+ })
220
+ .catch((error) => {
221
+ console.warn('Original audio could not be decoded:', error);
222
+ if (state.originalUrl === requestedUrl) {
223
+ state.originalBufferPromise = null;
224
+ state.originalFailed = true;
225
+ applySourceMix();
226
+ }
227
+ return null;
228
+ });
229
  }
230
+ return state.originalBufferPromise;
231
+ }
232
+
233
+ function startOriginalBufferSource(startAt, offset) {
234
+ stopOriginalBufferSource();
235
+ if (!state.audio || !state.originalGain || !state.originalBuffer) return;
236
+ const safeOffset = clamp(offset, 0, Math.max(0, state.originalBuffer.duration - .001));
237
+ if (safeOffset >= state.originalBuffer.duration) return;
238
+ const source = state.audio.createBufferSource();
239
+ source.buffer = state.originalBuffer;
240
+ source.connect(state.originalGain);
241
+ source.addEventListener('ended', () => {
242
+ if (state.originalBufferSource === source) state.originalBufferSource = null;
243
+ }, { once: true });
244
+ source.start(startAt, safeOffset);
245
+ state.originalBufferSource = source;
246
  }
247
 
248
  function applySourceMix() {
249
  const mix = clamp(Number(state.sourceMix), 0, 1);
250
+ const hasOriginal = Boolean(state.originalBuffer || (!state.originalFailed && discoverOriginalAudio()));
251
+ // Match the official client: a predictable linear blend in normal mode,
252
+ // with split mode overriding the blend so both sources remain audible.
253
+ const originalLevel = state.stereo && hasOriginal ? .62 : (hasOriginal ? 1 - mix : 0);
254
+ const midiLevel = state.stereo && hasOriginal ? .62 : (hasOriginal ? mix : 1);
255
  if (state.audio) {
256
  const now = state.audio.currentTime;
257
+ state.originalGain?.gain.setTargetAtTime(Math.max(.0001, originalLevel), now, .018);
258
  state.midiGain?.gain.setTargetAtTime(Math.max(.0001, midiLevel), now, .018);
259
  state.originalPanner?.pan.setTargetAtTime(state.stereo && hasOriginal ? -1 : 0, now, .018);
260
  state.midiPanner?.pan.setTargetAtTime(state.stereo && hasOriginal ? 1 : 0, now, .018);
261
  }
 
262
  $$('[data-source]').forEach((button) => {
263
  const selected = button.dataset.source === 'original' ? mix <= .01 : mix >= .99;
264
  button.setAttribute('aria-pressed', String(selected));
 
307
  }
308
 
309
  function scheduleNote(track, note, nowSong) {
310
+ const scheduledStart = state.originAudio + (note.start - state.originSong);
311
+ const scheduledEnd = state.originAudio + (note.end - state.originSong);
312
+ const startAt = Math.max(state.audio.currentTime + .006, scheduledStart);
313
+ const endAt = Math.max(startAt + .03, scheduledEnd);
314
  if (state.synth) {
315
  const channel = channelFor(track);
 
 
316
  state.synth.noteOn(channel, note.pitch, Math.round(note.velocity), { time: startAt });
317
+ state.synth.noteOff(channel, note.pitch, { time: Math.min(endAt, startAt + 12) });
318
  return;
319
  }
320
  const gain = state.gains.get(track.id);
321
  if (!state.audio || !gain) return;
322
+ const duration = clamp(endAt - startAt, .03, 8);
 
323
  const oscillator = state.audio.createOscillator();
324
  const envelope = state.audio.createGain();
325
  oscillator.type = waveform(track);
 
333
  }
334
 
335
  function exactTime() {
336
+ if (!state.playing || !state.audio) return state.songTime;
337
+ return clamp(
338
+ state.originSong + Math.max(0, state.audio.currentTime - state.originAudio),
339
+ 0,
340
+ state.data?.duration || Number.POSITIVE_INFINITY,
341
+ );
342
  }
343
 
344
  function lowerBound(time) {
 
350
  function stopPlayback(reset = false) {
351
  if (state.playing) state.songTime = clamp(exactTime(), 0, state.data?.duration || 0);
352
  state.playing = false; cancelAnimationFrame(state.animation);
353
+ stopOriginalBufferSource();
354
  state.nodes.forEach((node) => { try { node.stop(); } catch (_) {} }); state.nodes.clear();
355
  state.synth?.stopAll();
356
+ if (reset) state.songTime = 0;
357
+ ui.play.textContent = '▶'; ui.play.setAttribute('aria-label', 'Play');
 
 
 
358
  updateTimeline();
359
  }
360
 
 
362
  if (!state.data || !state.notes.length || state.playing) return;
363
  ensureAudio(); if (!state.audio) return;
364
  await state.audio.resume();
365
+ const originalPromise = loadOriginalBuffer();
366
+ await Promise.all([
367
+ state.synthPromise && !state.synthFailed ? state.synthPromise : Promise.resolve(null),
368
+ originalPromise,
369
+ ]);
370
  if (state.songTime >= state.data.duration - .02) state.songTime = 0;
371
+ state.originSong = state.songTime;
372
+ state.originAudio = state.audio.currentTime + .065;
373
+ startOriginalBufferSource(state.originAudio, state.songTime);
374
+ // Include sustained notes that began before a seek position. Twelve seconds
375
+ // matches the scheduler's maximum rendered note length.
376
+ state.nextNote = lowerBound(Math.max(0, state.songTime - 12)); state.playing = true;
377
+ ui.play.textContent = '❚❚'; ui.play.setAttribute('aria-label', 'Pause');
378
+ applySourceMix();
379
+ frame();
 
 
 
380
  }
381
 
382
  function frame() {
 
392
 
393
  function seekTo(time) {
394
  const resume = state.playing; stopPlayback(false); state.songTime = clamp(time, 0, state.data.duration);
 
395
  updateTimeline(); if (resume) startPlayback();
396
  }
397
 
 
402
 
403
  function renderTracks() {
404
  ui.tracks.replaceChildren();
405
+ if (!state.data.tracks.length) {
406
+ const empty = document.createElement('div');
407
+ empty.className = 'ms-track-placeholder';
408
+ empty.textContent = ['queued', 'loading', 'transcribing'].includes(state.data.state)
409
+ ? 'Instrument tracks will appear as transcription progresses.'
410
+ : 'Tracks will appear after transcription.';
411
+ ui.tracks.append(empty);
412
+ return;
413
+ }
414
  state.data.tracks.forEach((track) => {
415
  const row = document.createElement('article'); row.className = 'ms-track-row'; row.dataset.track = track.id; row.style.setProperty('--track', track.color);
416
  const dot = document.createElement('i'); dot.className = 'ms-track-color';
417
  const copy = document.createElement('div'); copy.className = 'ms-track-copy';
418
  const name = document.createElement('strong'); name.textContent = track.name;
419
+ const meta = document.createElement('span'); meta.textContent = `${track.note_count.toLocaleString('en-US')} notes${track.is_drum ? ' · drums' : ''}`; copy.append(name, meta);
420
  if (!track.is_drum) {
421
  const select = document.createElement('select');
422
+ select.className = 'ms-program-select'; select.dataset.program = track.id; select.setAttribute('aria-label', `Sound for ${track.name}`);
423
  const selectedProgram = state.programOverrides.get(track.id) ?? track.program;
424
  GM_PROGRAMS.forEach(([program, label]) => {
425
  const option = document.createElement('option'); option.value = String(program); option.textContent = label; option.selected = program === selectedProgram; select.append(option);
 
431
  }
432
  const controls = document.createElement('div'); controls.className = 'ms-track-controls';
433
  ['solo', 'mute'].forEach((kind) => { const button = document.createElement('button'); button.type = 'button'; button.dataset.mix = kind; button.textContent = kind === 'solo' ? 'S' : 'M'; button.title = kind === 'solo' ? 'Solo' : 'Mute'; button.setAttribute('aria-pressed', 'false'); controls.append(button); });
434
+ const download = document.createElement('a'); download.textContent = '↓'; download.title = `Download ${track.name}`;
435
  if (track.midi) { download.href = track.midi; download.download = `${track.key}.mid`; } else download.setAttribute('aria-disabled', 'true');
436
  controls.append(download); row.append(dot, copy, controls); ui.tracks.append(row);
437
  });
438
  }
439
 
440
  function drawRoll() {
441
+ if (!state.data || state.activeTab !== 'roll') return;
442
  const canvas = ui.canvas, rect = canvas.getBoundingClientRect(); if (!rect.width) return;
443
  const dpr = Math.min(window.devicePixelRatio || 1, 2), width = Math.round(rect.width * dpr), height = Math.round(rect.height * dpr);
444
  if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; }
445
  const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const w = rect.width, h = rect.height;
446
  ctx.clearRect(0, 0, w, h); ctx.fillStyle = '#090c11'; ctx.fillRect(0, 0, w, h);
447
  const allPitches = state.notes.map((item) => item.note.pitch); const minPitch = Math.min(...allPitches, 48) - 2, maxPitch = Math.max(...allPitches, 72) + 2, pitchSpan = maxPitch - minPitch + 1;
448
+ const duration = Math.max(.1, state.data.duration);
449
+ const zoom = Number(ui.zoom.value), span = Math.max(.1, duration / zoom);
450
+ const start = zoom === 1 ? 0 : clamp(state.songTime - span * .32, 0, Math.max(0, duration - span)); const end = Math.min(duration, start + span);
451
  state.viewStart = start; state.viewEnd = end;
452
  ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(255,255,255,.055)';
453
  for (let pitch = minPitch; pitch <= maxPitch; pitch++) { const y = h - ((pitch - minPitch + 1) / pitchSpan) * h; if ([1,3,6,8,10].includes(pitch % 12)) { ctx.fillStyle = 'rgba(255,255,255,.012)'; ctx.fillRect(0, y - h / pitchSpan, w, h / pitchSpan); } if (pitch % 12 === 0) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } }
 
469
  if (state.playing && previousSignature !== nextSignature) stopPlayback(true);
470
  if (audioChanged) {
471
  stopPlayback(true);
472
+ resetOriginalAudio();
473
  state.synth?.stopAll();
474
  state.channels.clear();
475
  state.nextChannel = 0;
 
479
  root.dataset.state = data.state; const active = ['queued', 'loading', 'transcribing'].includes(data.state);
480
  const hasResults = data.tracks.length > 0 || active;
481
  ui.empty.hidden = hasResults; ui.workspace.hidden = !hasResults;
482
+ ui.state.textContent = ({ idle: 'Ready for audio', queued: 'ZeroGPU queued', loading: 'Loading model', transcribing: 'Transcribing', complete: 'MIDI ready', ready: 'Score ready', error: 'Error', notation_error: 'MIDI ready' })[data.state] || data.state;
483
+ ui.status.textContent = data.status; ui.audioName.textContent = data.audio_name || 'Your transcription';
484
+ ui.summary.textContent = data.audio_name ? `${data.note_count.toLocaleString('en-US')} notes · ${data.tracks.length} tracks` : '—';
485
  ui.progress.hidden = !active; ui.progressLabel.textContent = data.status; ui.progressValue.textContent = `${Math.round(data.progress * 100)}%`; ui.progressBar.style.width = `${data.progress * 100}%`;
486
  ui.duration.textContent = formatTime(data.duration); ui.seek.max = String(Math.max(.01, data.duration));
487
+ setText('[data-field="track-count"]', data.tracks.length ? `${data.tracks.length} track${data.tracks.length > 1 ? 's' : ''}` : 'No tracks yet');
488
+ setText('[data-field="info-duration"]', formatTime(data.duration)); setText('[data-field="note-count"]', data.note_count ? data.note_count.toLocaleString('en-US') : '—');
489
  setText('[data-field="info-tracks"]', data.tracks.length || '—'); setText('[data-field="elapsed"]', data.elapsed ? `${data.elapsed.toFixed(1)} s` : '—');
490
+ const pitches = state.notes.map((item) => item.note.pitch); setText('[data-field="pitch-range"]', pitches.length ? `${noteName(Math.min(...pitches))} → ${noteName(Math.max(...pitches))}` : 'Waiting for notes');
491
  state.muted.forEach((id) => { if (!data.tracks.some((track) => track.id === id)) state.muted.delete(id); }); state.solo.forEach((id) => { if (!data.tracks.some((track) => track.id === id)) state.solo.delete(id); });
492
  renderTracks(); rebuildGains();
493
  ui.score.replaceChildren();
 
499
  const stem = (data.audio_name || 'muscriptor').replace(/\.[^.]+$/, '');
500
  updateDownload('[data-download="midi"]', data.full_midi, `${stem}.mid`); updateDownload('[data-download="musicxml"]', notation.musicxml || '', `${stem}.musicxml`); updateDownload('[data-download="svg"]', notation.svg || '', `${stem}.svg`);
501
  ui.play.disabled = !state.notes.length; ui.stop.disabled = !state.notes.length; updateTimeline();
502
+ if (state.audio) discoverOriginalAudio();
503
  applySourceMix();
504
  if (data.state === 'ready') switchTab('score');
505
  announce(`${ui.state.textContent}. ${data.status}`);
 
509
  if (state.playing) stopPlayback(false);
510
  else startPlayback().catch((error) => {
511
  console.error(error);
512
+ setSynthStatus('Playback unavailable', 'error');
513
  });
514
  });
515
  ui.stop.addEventListener('click', () => stopPlayback(true));
516
  ui.seek.addEventListener('input', () => state.data && seekTo(Number(ui.seek.value)));
517
  ui.volume.addEventListener('input', () => { if (state.master && state.audio) state.master.gain.setTargetAtTime(Number(ui.volume.value), state.audio.currentTime, .02); });
518
  ui.sourceMix.addEventListener('input', () => { state.sourceMix = Number(ui.sourceMix.value); applySourceMix(); });
519
+ ui.stereo.addEventListener('change', () => { state.stereo = ui.stereo.checked; applySourceMix(); announce(state.stereo ? 'Split mode: original left, MIDI right.' : 'Split mode off.'); });
520
  $$('[data-source]').forEach((button) => button.addEventListener('click', () => {
521
  state.sourceMix = button.dataset.source === 'original' ? 0 : 1;
522
  ui.sourceMix.value = String(state.sourceMix);
 
537
  state.synth.stopAll();
538
  state.synth.programChange(channel, program);
539
  }
540
+ announce(`Sound changed to ${select.options[select.selectedIndex].textContent}.`);
541
  });
542
  $$('[data-tab]').forEach((button) => button.addEventListener('click', () => switchTab(button.dataset.tab)));
543
  new ResizeObserver(() => drawRoll()).observe(ui.canvas);