Spaces:
Running on Zero
Running on Zero
| """DJ-console visual assets for ACE-Step Inspire (CSS + knob HTML/JS).""" | |
| CONSOLE_CSS = r""" | |
| @import url('https://fonts.googleapis.com/css2?family=Audiowide&family=Rajdhani:wght@500;600;700&family=Share+Tech+Mono&display=swap'); | |
| :root { | |
| --bg0: #07080b; | |
| --bg1: #0e1016; | |
| --panel: #141821; | |
| --panel-2: #1a1f2b; | |
| --line: #2a3142; | |
| --metal: #9aa3b2; | |
| --muted: #6b7385; | |
| --amber: #ffb020; | |
| --amber-dim: #c48212; | |
| --led: #3dffb0; | |
| --led-dim: #1a6b4d; | |
| --danger: #ff5c5c; | |
| --text: #e8ecf4; | |
| } | |
| html, body { | |
| background: var(--bg0) !important; | |
| color-scheme: dark !important; | |
| } | |
| .gradio-container { | |
| max-width: 100% !important; | |
| width: 100% !important; | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| background: | |
| radial-gradient(1200px 500px at 50% -10%, rgba(255,176,32,0.08), transparent 60%), | |
| radial-gradient(900px 400px at 100% 20%, rgba(61,255,176,0.05), transparent 50%), | |
| linear-gradient(180deg, var(--bg1), var(--bg0)) !important; | |
| font-family: 'Rajdhani', sans-serif !important; | |
| color: var(--text) !important; | |
| min-height: 100vh; | |
| color-scheme: dark !important; | |
| } | |
| footer, .svelte-1ipelgc { display: none !important; } | |
| /* Kill default card chrome */ | |
| .console-shell .block, | |
| .console-shell .form, | |
| .console-shell .panel-wrap { | |
| background: transparent !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| } | |
| .console-shell label, | |
| .console-shell .label-wrap span { | |
| font-family: 'Share Tech Mono', monospace !important; | |
| font-size: 0.72rem !important; | |
| letter-spacing: 0.12em !important; | |
| text-transform: uppercase !important; | |
| color: var(--muted) !important; | |
| } | |
| /* Force dark fields even when the OS / browser prefers light UI */ | |
| .console-shell, | |
| .console-shell * { | |
| color-scheme: dark !important; | |
| } | |
| .console-shell input, | |
| .console-shell textarea, | |
| .console-shell select, | |
| .console-shell .wrap input, | |
| .console-shell .wrap textarea, | |
| .console-shell [data-testid="dropdown"] input, | |
| .console-shell [data-testid="textbox"] textarea, | |
| .console-shell [data-testid="textbox"] input, | |
| .console-shell .secondary-wrap input { | |
| background: #0a0c11 !important; | |
| background-color: #0a0c11 !important; | |
| border: 1px solid var(--line) !important; | |
| border-radius: 4px !important; | |
| color: var(--text) !important; | |
| -webkit-text-fill-color: var(--text) !important; | |
| caret-color: var(--text) !important; | |
| font-family: 'Rajdhani', sans-serif !important; | |
| font-size: 1rem !important; | |
| color-scheme: dark !important; | |
| } | |
| .console-shell input::placeholder, | |
| .console-shell textarea::placeholder { | |
| color: var(--muted) !important; | |
| -webkit-text-fill-color: var(--muted) !important; | |
| opacity: 1 !important; | |
| } | |
| .console-shell select option, | |
| .console-shell option { | |
| background: #0a0c11 !important; | |
| color: var(--text) !important; | |
| } | |
| /* Gradio dropdown option panels (often portaled to body) */ | |
| .options, | |
| ul.options, | |
| div.options, | |
| .global-container .options, | |
| body .options, | |
| body ul[role="listbox"], | |
| div[role="listbox"] { | |
| background: #141821 !important; | |
| background-color: #141821 !important; | |
| border: 1px solid var(--line) !important; | |
| color: var(--text) !important; | |
| color-scheme: dark !important; | |
| } | |
| .options li, | |
| .options .item, | |
| ul.options > *, | |
| div[role="listbox"] [role="option"], | |
| body .options li, | |
| body .options .item { | |
| background: #141821 !important; | |
| background-color: #141821 !important; | |
| color: var(--text) !important; | |
| -webkit-text-fill-color: var(--text) !important; | |
| } | |
| .options li:hover, | |
| .options .item:hover, | |
| .options li.selected, | |
| .options .item.selected, | |
| div[role="listbox"] [role="option"][aria-selected="true"], | |
| div[role="listbox"] [role="option"]:hover { | |
| background: #1f2633 !important; | |
| background-color: #1f2633 !important; | |
| color: var(--amber) !important; | |
| -webkit-text-fill-color: var(--amber) !important; | |
| } | |
| .console-shell .secondary-wrap, | |
| .console-shell [data-testid="checkbox-group"] { | |
| gap: 0.45rem !important; | |
| } | |
| /* Equal side racks */ | |
| .rack-row > div.rack-panel { | |
| min-width: 0 !important; | |
| } | |
| /* Brand bar */ | |
| .brand-bar { | |
| display: flex; | |
| align-items: baseline; | |
| justify-content: space-between; | |
| padding: 0.85rem 1rem 0.4rem; | |
| border-bottom: 1px solid var(--line); | |
| box-sizing: border-box; | |
| width: 100%; | |
| } | |
| .brand-bar .booth { | |
| font-family: 'Audiowide', sans-serif; | |
| font-size: 1.55rem; | |
| letter-spacing: 0.18em; | |
| color: var(--amber); | |
| text-shadow: 0 0 18px rgba(255,176,32,0.35); | |
| } | |
| .brand-bar .sub { | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.72rem; | |
| letter-spacing: 0.04em; | |
| color: var(--muted); | |
| margin-top: 0.2rem; | |
| max-width: 42rem; | |
| line-height: 1.35; | |
| } | |
| .brand-bar .sub a { | |
| color: var(--amber); | |
| text-decoration: none; | |
| border-bottom: 1px solid rgba(255, 176, 32, 0.45); | |
| } | |
| .brand-bar .sub a:hover { | |
| color: #ffc95a; | |
| border-bottom-color: var(--amber); | |
| } | |
| .brand-bar .live { | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.7rem; | |
| color: var(--led); | |
| letter-spacing: 0.16em; | |
| } | |
| .brand-bar .live::before { | |
| content: ''; | |
| display: inline-block; | |
| width: 8px; height: 8px; | |
| border-radius: 50%; | |
| background: var(--led); | |
| margin-right: 8px; | |
| box-shadow: 0 0 10px var(--led); | |
| animation: pulse-led 1.8s ease-in-out infinite; | |
| } | |
| @keyframes pulse-led { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.45; } | |
| } | |
| /* Deck row — same gutter as rack-row; no Group card (transparent shell) */ | |
| div.row.deck-shell, | |
| div.row.deck-shell-row, | |
| .deck-shell, | |
| .deck-shell-row { | |
| display: grid !important; | |
| grid-template-columns: minmax(0, 1fr) minmax(0, 7fr) !important; | |
| gap: 0.75rem !important; | |
| align-items: stretch !important; | |
| margin: 0.75rem 0 0.5rem !important; | |
| padding: 0 1rem !important; | |
| width: 100% !important; | |
| max-width: 100% !important; | |
| box-sizing: border-box !important; | |
| overflow: hidden !important; | |
| flex-wrap: nowrap !important; | |
| background: transparent !important; | |
| background-color: transparent !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| } | |
| div.row.deck-shell-row > div, | |
| .deck-shell-row > div { | |
| min-width: 0 !important; | |
| max-width: 100% !important; | |
| align-self: stretch !important; | |
| height: auto !important; | |
| box-sizing: border-box !important; | |
| } | |
| .deck-shell-row > .deck-gen-col, | |
| div.row.deck-shell-row > div.deck-gen-col { | |
| flex: unset !important; | |
| width: auto !important; | |
| max-width: none !important; | |
| } | |
| .deck-shell-row > .deck-panel, | |
| div.row.deck-shell-row > div.deck-panel { | |
| flex: unset !important; | |
| width: auto !important; | |
| max-width: 100% !important; | |
| min-width: 0 !important; | |
| } | |
| .deck-gen-col, | |
| .deck-gen-col > div, | |
| .deck-gen-col .block, | |
| .deck-gen-col .form, | |
| .deck-gen-col button, | |
| #generate-song-btn { | |
| height: 100% !important; | |
| min-height: 100% !important; | |
| box-sizing: border-box !important; | |
| } | |
| #generate-song-btn { | |
| width: 100% !important; | |
| background: linear-gradient(180deg, #ffc14d, #e09100) !important; | |
| color: #1a1200 !important; | |
| border: none !important; | |
| border-radius: 10px !important; | |
| box-shadow: 0 0 24px rgba(255,176,32,0.35); | |
| font-family: 'Audiowide', sans-serif !important; | |
| letter-spacing: 0.06em !important; | |
| font-size: 0.92rem !important; | |
| white-space: pre-line !important; | |
| line-height: 1.2 !important; | |
| padding: 0.85rem 0.45rem !important; | |
| text-align: center !important; | |
| } | |
| #generate-song-btn:hover { | |
| filter: brightness(1.05); | |
| } | |
| /* Original deck panel look */ | |
| .deck-panel { | |
| margin: 0; | |
| padding: 0.75rem 1rem 1rem; | |
| background: linear-gradient(180deg, #12151d, #0b0d12); | |
| border: 1px solid var(--line); | |
| border-radius: 10px; | |
| box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03), 0 10px 40px rgba(0,0,0,0.35); | |
| min-width: 0 !important; | |
| min-height: 233px !important; | |
| overflow: hidden !important; | |
| box-sizing: border-box !important; | |
| max-width: 100% !important; | |
| } | |
| .deck-panel .deck-label { | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.68rem; | |
| letter-spacing: 0.18em; | |
| color: var(--muted); | |
| margin-bottom: 0.35rem; | |
| } | |
| .deck-panel .empty-hint { | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.75rem; | |
| color: var(--muted); | |
| opacity: 0.85; | |
| margin: 0.25rem 0 0.5rem; | |
| } | |
| #deck-audio, | |
| #deck-audio .audio-container, | |
| #deck-audio audio, | |
| .deck-panel audio { | |
| width: 100% !important; | |
| max-width: 100% !important; | |
| box-sizing: border-box !important; | |
| } | |
| .deck-panel .audio-container { | |
| background: transparent !important; | |
| border: none !important; | |
| padding: 0 !important; | |
| } | |
| .deck-live #deck-audio { | |
| filter: drop-shadow(0 0 12px rgba(61,255,176,0.15)); | |
| } | |
| /* Racks */ | |
| /* Racks — same 1rem side gutter as deck-shell */ | |
| .rack-row { | |
| padding: 0.5rem 1rem 0.25rem !important; | |
| align-items: stretch !important; | |
| width: 100% !important; | |
| max-width: 100% !important; | |
| box-sizing: border-box !important; | |
| margin: 0 !important; | |
| } | |
| .rack-panel { | |
| background: linear-gradient(165deg, var(--panel-2), var(--panel)); | |
| border: 1px solid var(--line); | |
| border-radius: 10px; | |
| padding: 0.85rem 0.9rem 1rem; | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); | |
| height: 100%; | |
| min-height: 520px; | |
| } | |
| .rack-title { | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.68rem; | |
| letter-spacing: 0.2em; | |
| color: var(--amber-dim); | |
| margin: 0 0 0.75rem; | |
| padding-bottom: 0.4rem; | |
| border-bottom: 1px solid var(--line); | |
| } | |
| /* Instrument browser — selected chips + category accordion */ | |
| .inst-browser { | |
| margin: 0.35rem 0 0.85rem; | |
| } | |
| .inst-browser .pad-field-label { | |
| margin-bottom: 0.45rem; | |
| } | |
| .inst-active { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.35rem; | |
| min-height: 2rem; | |
| margin-bottom: 0.55rem; | |
| } | |
| .inst-active:empty + .inst-empty { | |
| display: block; | |
| } | |
| .inst-empty { | |
| display: none; | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.65rem; | |
| letter-spacing: 0.08em; | |
| color: var(--muted); | |
| margin: -0.25rem 0 0.55rem; | |
| } | |
| .inst-chip { | |
| appearance: none; | |
| -webkit-appearance: none; | |
| cursor: pointer; | |
| background: linear-gradient(180deg, #222836, #151922); | |
| border: 1px solid var(--amber); | |
| border-radius: 6px; | |
| padding: 0.38rem 0.55rem; | |
| color: var(--amber); | |
| font-family: 'Rajdhani', sans-serif; | |
| font-weight: 600; | |
| font-size: 0.85rem; | |
| letter-spacing: 0.03em; | |
| box-shadow: 0 0 12px rgba(255,176,32,0.2), inset 0 0 8px rgba(255,176,32,0.06); | |
| } | |
| .inst-chip:hover { | |
| border-color: var(--danger); | |
| color: var(--danger); | |
| box-shadow: none; | |
| } | |
| .inst-chip .x { | |
| margin-left: 0.35rem; | |
| opacity: 0.7; | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.75rem; | |
| } | |
| .inst-groups { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.3rem; | |
| border-top: 1px dashed var(--line); | |
| padding-top: 0.45rem; | |
| } | |
| .inst-group { | |
| border: 1px solid var(--line); | |
| border-radius: 6px; | |
| background: #0b0d13; | |
| overflow: hidden; | |
| } | |
| .inst-group-head { | |
| appearance: none; | |
| -webkit-appearance: none; | |
| width: 100%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 0.5rem; | |
| cursor: pointer; | |
| background: transparent; | |
| border: none; | |
| padding: 0.45rem 0.6rem; | |
| color: var(--metal); | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.68rem; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| text-align: left; | |
| } | |
| .inst-group-head:hover { | |
| color: var(--text); | |
| } | |
| .inst-group.open .inst-group-head { | |
| color: var(--amber); | |
| border-bottom: 1px solid var(--line); | |
| } | |
| .inst-group-head .count { | |
| color: var(--muted); | |
| letter-spacing: 0.06em; | |
| } | |
| .inst-group.open .inst-group-head .count { | |
| color: var(--led); | |
| } | |
| .inst-group-head .chev { | |
| color: var(--muted); | |
| transition: transform 0.12s ease; | |
| } | |
| .inst-group.open .inst-group-head .chev { | |
| transform: rotate(90deg); | |
| color: var(--amber); | |
| } | |
| .inst-group-body { | |
| display: none; | |
| flex-wrap: wrap; | |
| gap: 0.35rem; | |
| padding: 0.5rem 0.55rem 0.55rem; | |
| max-height: 180px; | |
| overflow-y: auto; | |
| } | |
| .inst-group.open .inst-group-body { | |
| display: flex; | |
| } | |
| .inst-option { | |
| appearance: none; | |
| -webkit-appearance: none; | |
| cursor: pointer; | |
| background: linear-gradient(180deg, #222836, #151922); | |
| border: 1px solid #323a4d; | |
| border-radius: 6px; | |
| padding: 0.38rem 0.55rem; | |
| color: var(--metal); | |
| font-family: 'Rajdhani', sans-serif; | |
| font-weight: 600; | |
| font-size: 0.82rem; | |
| letter-spacing: 0.03em; | |
| box-shadow: 0 2px 0 #0a0c10; | |
| } | |
| .inst-option:hover { | |
| border-color: #4a5568; | |
| color: var(--text); | |
| } | |
| .inst-option.active { | |
| border-color: var(--amber); | |
| color: var(--amber); | |
| box-shadow: 0 0 10px rgba(255,176,32,0.2); | |
| } | |
| /* Gradio instrument checkbox group stays for state; UI is .inst-browser */ | |
| #instrument-pads { | |
| position: absolute !important; | |
| width: 1px !important; | |
| height: 1px !important; | |
| overflow: hidden !important; | |
| clip: rect(0,0,0,0) !important; | |
| opacity: 0 !important; | |
| pointer-events: none !important; | |
| } | |
| /* Shared exclusive pad / chip grids */ | |
| .pad-field { | |
| margin: 0 0 0.75rem; | |
| } | |
| .pad-field-label { | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.65rem; | |
| letter-spacing: 0.14em; | |
| color: var(--muted); | |
| text-transform: uppercase; | |
| margin: 0 0 0.4rem; | |
| } | |
| .style-guide { | |
| margin: -0.35rem 0 0.85rem; | |
| padding: 0.55rem 0.65rem 0.6rem; | |
| border-left: 2px solid var(--amber-dim); | |
| background: linear-gradient(90deg, rgba(255,176,32,0.06), transparent 70%); | |
| } | |
| .style-guide .pad-field-label { | |
| color: var(--amber-dim); | |
| margin-bottom: 0.3rem; | |
| } | |
| .style-guide-body { | |
| margin: 0; | |
| font-family: 'Rajdhani', sans-serif; | |
| font-size: 0.92rem; | |
| font-weight: 500; | |
| line-height: 1.35; | |
| color: var(--metal); | |
| letter-spacing: 0.01em; | |
| } | |
| .pad-grid { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.4rem; | |
| } | |
| .pad-grid.seg-col { | |
| flex-direction: column; | |
| flex-wrap: nowrap; | |
| } | |
| .pad { | |
| appearance: none; | |
| -webkit-appearance: none; | |
| cursor: pointer; | |
| background: linear-gradient(180deg, #222836, #151922); | |
| border: 1px solid #323a4d; | |
| border-radius: 6px; | |
| padding: 0.45rem 0.65rem; | |
| color: var(--metal); | |
| font-family: 'Rajdhani', sans-serif; | |
| font-weight: 600; | |
| font-size: 0.88rem; | |
| letter-spacing: 0.03em; | |
| line-height: 1.2; | |
| text-align: left; | |
| box-shadow: 0 2px 0 #0a0c10; | |
| transition: transform 0.08s ease, box-shadow 0.15s ease, border-color 0.15s, color 0.15s; | |
| } | |
| .pad:hover { | |
| border-color: #4a5568; | |
| color: var(--text); | |
| } | |
| .pad.active { | |
| border-color: var(--amber); | |
| color: var(--amber); | |
| box-shadow: 0 0 12px rgba(255,176,32,0.25), inset 0 0 10px rgba(255,176,32,0.08); | |
| transform: translateY(1px); | |
| } | |
| .pad.compact { | |
| font-size: 0.8rem; | |
| padding: 0.38rem 0.55rem; | |
| } | |
| .pad.toggle-wide { | |
| width: 100%; | |
| text-align: center; | |
| text-transform: uppercase; | |
| letter-spacing: 0.12em; | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.72rem; | |
| } | |
| /* Duration dial — layout mirror of the BPM knob (right rack) */ | |
| .duration-bay { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| user-select: none; | |
| margin: 0.15rem 0 1rem; | |
| } | |
| .duration-bay .dur-face { | |
| width: 148px; | |
| height: 148px; | |
| border-radius: 50%; | |
| background: | |
| radial-gradient(circle at 35% 30%, #3a4255, #171b24 55%, #0a0c10 80%), | |
| conic-gradient(from 0deg, #2a3140, #4a5366, #2a3140); | |
| border: 3px solid #4a5368; | |
| box-shadow: | |
| inset 0 0 18px rgba(0,0,0,0.65), | |
| 0 8px 18px rgba(0,0,0,0.45), | |
| 0 0 0 4px #0c0e14; | |
| position: relative; | |
| cursor: grab; | |
| touch-action: none; | |
| } | |
| .duration-bay .dur-face:active { cursor: grabbing; } | |
| .duration-bay .dur-face::before { | |
| content: ''; | |
| position: absolute; | |
| inset: 14px; | |
| border-radius: 50%; | |
| background: radial-gradient(circle at 40% 35%, #2b3240, #12151c); | |
| border: 1px solid #3a4254; | |
| } | |
| .duration-bay .dur-pointer { | |
| position: absolute; | |
| left: 50%; | |
| top: 18px; | |
| width: 4px; | |
| height: 42px; | |
| margin-left: -2px; | |
| border-radius: 2px; | |
| background: linear-gradient(180deg, var(--led), #1a6b4d); | |
| box-shadow: 0 0 10px rgba(61,255,176,0.55); | |
| transform-origin: 50% 56px; | |
| z-index: 2; | |
| } | |
| .duration-bay .dur-mmss { | |
| margin-top: 0.65rem; | |
| font-family: 'Audiowide', sans-serif; | |
| font-size: 1.65rem; | |
| color: var(--led); | |
| text-shadow: 0 0 12px rgba(61,255,176,0.35); | |
| letter-spacing: 0.08em; | |
| } | |
| .duration-bay .dur-caption { | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.65rem; | |
| letter-spacing: 0.18em; | |
| color: var(--muted); | |
| margin-top: 0.15rem; | |
| } | |
| .duration-bay .dur-slider-wrap { | |
| width: 100%; | |
| max-width: 168px; | |
| margin-top: 0.65rem; | |
| } | |
| .duration-bay .dur-slider-wrap input[type=range] { | |
| width: 100%; | |
| accent-color: var(--led); | |
| cursor: pointer; | |
| } | |
| .duration-bay .dur-marks { | |
| display: flex; | |
| justify-content: space-between; | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.58rem; | |
| letter-spacing: 0.06em; | |
| color: var(--muted); | |
| margin-top: 0.2rem; | |
| } | |
| /* Hide Gradio fields driven by pad UI */ | |
| .pad-hidden, | |
| #bpm_number, | |
| #auto_download, | |
| #auto_play { | |
| position: absolute !important; | |
| width: 1px !important; | |
| height: 1px !important; | |
| overflow: hidden !important; | |
| clip: rect(0,0,0,0) !important; | |
| opacity: 0 !important; | |
| pointer-events: none !important; | |
| } | |
| /* Action bar */ | |
| .action-bar { | |
| margin: 0.6rem 0 1.25rem !important; | |
| padding: 0.75rem 1rem !important; | |
| width: 100% !important; | |
| max-width: 100% !important; | |
| box-sizing: border-box !important; | |
| background: #0d1017; | |
| border: none; | |
| border-radius: 0; | |
| border-top: 1px solid var(--line); | |
| border-bottom: 1px solid var(--line); | |
| display: flex; | |
| gap: 0.75rem; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| } | |
| .action-bar button { | |
| font-family: 'Audiowide', sans-serif !important; | |
| letter-spacing: 0.08em !important; | |
| } | |
| .ghost-btn button, | |
| #prompt-btn, #info-btn, #surprise-btn, #expert-btn { | |
| background: #171b25 !important; | |
| border: 1px solid var(--line) !important; | |
| color: var(--metal) !important; | |
| } | |
| /* Presets in MACHINE column (localStorage) */ | |
| .preset-bar { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.4rem; | |
| align-items: center; | |
| margin: 0.85rem 0 0.15rem; | |
| padding-top: 0.65rem; | |
| border-top: 1px dashed var(--line); | |
| width: 100%; | |
| max-width: 100%; | |
| box-sizing: border-box; | |
| } | |
| .preset-bar .preset-label { | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.65rem; | |
| letter-spacing: 0.14em; | |
| color: var(--muted); | |
| width: 100%; | |
| margin: 0 0 0.15rem; | |
| } | |
| .preset-bar input, | |
| .preset-bar select { | |
| background: #0a0c11 !important; | |
| border: 1px solid var(--line) !important; | |
| border-radius: 4px !important; | |
| color: var(--text) !important; | |
| font-family: 'Rajdhani', sans-serif !important; | |
| font-size: 0.88rem !important; | |
| padding: 0.35rem 0.5rem !important; | |
| min-height: 34px; | |
| flex: 1 1 auto; | |
| min-width: 0; | |
| box-sizing: border-box; | |
| } | |
| .preset-bar input { width: 100%; flex: 1 1 100%; } | |
| .preset-bar select { width: 100%; flex: 1 1 100%; min-width: 0; max-width: none; } | |
| .preset-bar button { | |
| background: #171b25 !important; | |
| border: 1px solid var(--line) !important; | |
| color: var(--metal) !important; | |
| font-family: 'Share Tech Mono', monospace !important; | |
| font-size: 0.68rem !important; | |
| letter-spacing: 0.1em !important; | |
| text-transform: uppercase !important; | |
| padding: 0.4rem 0.55rem !important; | |
| border-radius: 4px !important; | |
| cursor: pointer; | |
| flex: 1 1 auto; | |
| } | |
| .preset-bar button:hover { | |
| border-color: var(--amber) !important; | |
| color: var(--amber) !important; | |
| } | |
| .preset-bar .preset-status { | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.65rem; | |
| letter-spacing: 0.08em; | |
| color: var(--led); | |
| width: 100%; | |
| min-height: 1em; | |
| } | |
| /* After-generate toggle pads (download / play) */ | |
| .auto-opts { | |
| margin: 0.65rem 0 0.25rem; | |
| padding: 0.55rem 0.55rem 0.45rem; | |
| background: #0b0d13; | |
| border: 1px solid var(--line); | |
| border-radius: 8px; | |
| } | |
| .auto-opts .pad-field-label { | |
| margin-bottom: 0.45rem; | |
| } | |
| .auto-opts .pad-grid { | |
| gap: 0.35rem; | |
| } | |
| .auto-opts .pad.toggle-auto { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 0.5rem; | |
| width: 100%; | |
| text-align: left; | |
| text-transform: uppercase; | |
| letter-spacing: 0.1em; | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.7rem; | |
| padding: 0.5rem 0.65rem; | |
| } | |
| .auto-opts .pad.toggle-auto .auto-led { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: #2a3142; | |
| box-shadow: inset 0 0 0 1px #1a1f2b; | |
| flex-shrink: 0; | |
| } | |
| .auto-opts .pad.toggle-auto.active { | |
| border-color: var(--led); | |
| color: var(--led); | |
| box-shadow: 0 0 14px rgba(61,255,176,0.18), inset 0 0 10px rgba(61,255,176,0.06); | |
| } | |
| .auto-opts .pad.toggle-auto.active .auto-led { | |
| background: var(--led); | |
| box-shadow: 0 0 10px var(--led); | |
| } | |
| /* Off-screen DownloadButton used for auto-save after generate */ | |
| #inspire-autodl { | |
| position: fixed !important; | |
| left: -10000px !important; | |
| top: 0 !important; | |
| width: 1px !important; | |
| height: 1px !important; | |
| opacity: 0.01 !important; | |
| overflow: hidden !important; | |
| z-index: -1 !important; | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| border: none !important; | |
| min-width: 0 !important; | |
| } | |
| /* Tempo knob */ | |
| .knob-wrap { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| user-select: none; | |
| margin: 0.25rem 0 0.75rem; | |
| } | |
| .knob-face { | |
| width: 148px; | |
| height: 148px; | |
| border-radius: 50%; | |
| background: | |
| radial-gradient(circle at 35% 30%, #3a4255, #171b24 55%, #0a0c10 80%), | |
| conic-gradient(from 0deg, #2a3140, #4a5366, #2a3140); | |
| border: 3px solid #4a5368; | |
| box-shadow: | |
| inset 0 0 18px rgba(0,0,0,0.65), | |
| 0 8px 18px rgba(0,0,0,0.45), | |
| 0 0 0 4px #0c0e14; | |
| position: relative; | |
| cursor: grab; | |
| touch-action: none; | |
| } | |
| .knob-face:active { cursor: grabbing; } | |
| .knob-face::before { | |
| content: ''; | |
| position: absolute; | |
| inset: 14px; | |
| border-radius: 50%; | |
| background: radial-gradient(circle at 40% 35%, #2b3240, #12151c); | |
| border: 1px solid #3a4254; | |
| } | |
| .knob-pointer { | |
| position: absolute; | |
| left: 50%; | |
| top: 18px; | |
| width: 4px; | |
| height: 42px; | |
| margin-left: -2px; | |
| border-radius: 2px; | |
| background: linear-gradient(180deg, var(--amber), #8a5a00); | |
| box-shadow: 0 0 10px rgba(255,176,32,0.55); | |
| transform-origin: 50% 56px; | |
| z-index: 2; | |
| } | |
| .knob-bpm { | |
| margin-top: 0.65rem; | |
| font-family: 'Audiowide', sans-serif; | |
| font-size: 1.65rem; | |
| color: var(--led); | |
| text-shadow: 0 0 12px rgba(61,255,176,0.35); | |
| letter-spacing: 0.06em; | |
| } | |
| .knob-caption { | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.65rem; | |
| letter-spacing: 0.18em; | |
| color: var(--muted); | |
| margin-top: 0.15rem; | |
| } | |
| /* Modal overlay */ | |
| .modal-shell { | |
| position: fixed !important; | |
| inset: 0; | |
| z-index: 1000; | |
| background: rgba(4,5,8,0.72); | |
| backdrop-filter: blur(4px); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 1.5rem; | |
| } | |
| .modal-card { | |
| width: min(720px, 94vw); | |
| max-height: 80vh; | |
| overflow: auto; | |
| background: #141821; | |
| border: 1px solid var(--line); | |
| border-radius: 12px; | |
| padding: 1rem 1.1rem 1.2rem; | |
| box-shadow: 0 20px 60px rgba(0,0,0,0.55); | |
| } | |
| .modal-card .rack-title { color: var(--amber); } | |
| /* Expert modal — compact control stack */ | |
| .expert-modal-card { | |
| width: min(420px, 94vw); | |
| } | |
| .expert-modal-card .block { | |
| margin-bottom: 0.35rem !important; | |
| } | |
| /* Mood board */ | |
| .mood-board { | |
| display: grid; | |
| grid-template-columns: 1fr 170px; | |
| gap: 0.75rem; | |
| align-items: stretch; | |
| margin: 0.35rem 0 0.85rem; | |
| padding: 0.65rem; | |
| background: #0b0d13; | |
| border: 1px solid var(--line); | |
| border-radius: 8px; | |
| } | |
| .mood-faders { | |
| display: flex; | |
| justify-content: flex-start; | |
| gap: 0.28rem; | |
| min-height: 168px; | |
| overflow-x: auto; | |
| overflow-y: hidden; | |
| padding-bottom: 0.35rem; | |
| } | |
| .mood-fader { | |
| flex: 0 0 30px; | |
| width: 30px; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 0.3rem; | |
| } | |
| .mood-fader .track { | |
| position: relative; | |
| width: 18px; | |
| flex: 1; | |
| background: linear-gradient(180deg, #1c2230, #0a0c11); | |
| border: 1px solid #2e3648; | |
| border-radius: 4px; | |
| cursor: ns-resize; | |
| touch-action: none; | |
| } | |
| .mood-fader .fill { | |
| position: absolute; | |
| left: 2px; right: 2px; bottom: 2px; | |
| border-radius: 2px; | |
| background: linear-gradient(180deg, var(--amber), #6a3f00); | |
| box-shadow: 0 0 8px rgba(255,176,32,0.25); | |
| height: 50%; | |
| pointer-events: none; | |
| } | |
| .mood-fader .cap { | |
| position: absolute; | |
| left: -3px; right: -3px; | |
| height: 10px; | |
| border-radius: 2px; | |
| background: #d7dde8; | |
| border: 1px solid #8a93a5; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.4); | |
| transform: translateY(50%); | |
| bottom: 50%; | |
| pointer-events: none; | |
| } | |
| .mood-fader .ax { | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.52rem; | |
| letter-spacing: 0.02em; | |
| color: var(--muted); | |
| text-transform: lowercase; | |
| writing-mode: vertical-rl; | |
| transform: rotate(180deg); | |
| height: 4.2rem; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .mood-radar-wrap { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| #mood-radar { | |
| width: 148px; | |
| height: 148px; | |
| } | |
| .mood-derived { | |
| display: flex; | |
| gap: 0.5rem; | |
| flex-wrap: wrap; | |
| margin-top: 0.35rem; | |
| } | |
| .mood-chip { | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.68rem; | |
| letter-spacing: 0.08em; | |
| padding: 0.35rem 0.55rem; | |
| border-radius: 4px; | |
| border: 1px solid var(--line); | |
| background: #10141c; | |
| color: var(--led); | |
| } | |
| .mood-chip span { color: var(--muted); margin-right: 0.35rem; } | |
| /* Hide native Gradio chrome for derived fields we drive from the board */ | |
| #mood_dims, #mood_label_box, #key_box, #meter_box { | |
| position: absolute !important; width: 1px !important; height: 1px !important; | |
| overflow: hidden !important; clip: rect(0,0,0,0) !important; opacity: 0 !important; | |
| } | |
| @media (max-width: 1100px) { | |
| .rack-panel { min-height: unset; } | |
| .mood-board { grid-template-columns: 1fr; } | |
| div.row.deck-shell-row, | |
| .deck-shell-row { | |
| grid-template-columns: 1fr !important; | |
| } | |
| .deck-shell-row > .deck-gen-col, | |
| div.row.deck-shell-row > div.deck-gen-col { | |
| max-width: none !important; | |
| width: 100% !important; | |
| } | |
| #generate-song-btn { | |
| min-height: 52px !important; | |
| height: 52px !important; | |
| } | |
| .preset-bar { width: 100%; } | |
| } | |
| """ | |
| def _pad_field(label: str, grid_id: str, extra_class: str = "") -> str: | |
| cls = f"pad-grid {extra_class}".strip() | |
| return f""" | |
| <div class="pad-field"> | |
| <div class="pad-field-label">{label}</div> | |
| <div class="{cls}" id="{grid_id}"></div> | |
| </div> | |
| """ | |
| MODEL_PADS_HTML = "" # unused — model uses full Gradio dropdown again | |
| FORMAT_PADS_HTML = _pad_field("Export format", "pads-format") | |
| DURATION_BAY_HTML = """ | |
| <div class="duration-bay" id="duration-bay"> | |
| <div class="dur-face" id="dur-face" title="Drag around the dial · scroll to nudge · exponential time"> | |
| <div class="dur-pointer" id="dur-pointer"></div> | |
| </div> | |
| <div class="dur-mmss"><span id="dur-mmss">03:00</span></div> | |
| <div class="dur-caption">DURATION · EXP</div> | |
| <div class="dur-slider-wrap"> | |
| <input type="range" id="dur-slider" min="0" max="1000" step="1" value="500" aria-label="Duration exponential" /> | |
| <div class="dur-marks"><span>0</span><span>3:00</span><span>60:00</span></div> | |
| </div> | |
| </div> | |
| """ | |
| GENRE_PADS_HTML = _pad_field("Genre", "pads-genre") | |
| STYLE_PADS_HTML = _pad_field("Style", "pads-style") + """ | |
| <div class="style-guide" id="style-guide"> | |
| <div class="pad-field-label">About this style</div> | |
| <p class="style-guide-body" id="style-guide-body"></p> | |
| </div> | |
| """ | |
| USECASE_PADS_HTML = _pad_field("Use case", "pads-usecase") | |
| VOCAL_PADS_HTML = _pad_field("Vocal character", "pads-vocal") | |
| LANGUAGE_PADS_HTML = _pad_field("Language", "pads-language") | |
| STRUCTURE_PADS_HTML = _pad_field("Structure", "pads-structure") | |
| INSTRUMENTAL_PAD_HTML = """ | |
| <div class="pad-field"> | |
| <button type="button" class="pad toggle-wide" id="pad-instrumental" data-on="0">Instrumental · off</button> | |
| </div> | |
| """ | |
| AUTO_OPTS_HTML = """ | |
| <div class="pad-field auto-opts" id="auto-opts"> | |
| <div class="pad-field-label">After generate</div> | |
| <div class="pad-grid seg-col"> | |
| <button type="button" class="pad toggle-auto" id="pad-auto-download" data-on="0"> | |
| <span>Download auto</span><span class="auto-led" aria-hidden="true"></span> | |
| </button> | |
| <button type="button" class="pad toggle-auto" id="pad-auto-play" data-on="0"> | |
| <span>Play auto</span><span class="auto-led" aria-hidden="true"></span> | |
| </button> | |
| </div> | |
| </div> | |
| """ | |
| INSTRUMENTS_BROWSER_HTML = """ | |
| <div class="inst-browser" id="inst-browser"> | |
| <div class="pad-field-label">Instruments / textures</div> | |
| <div class="inst-active" id="inst-active"></div> | |
| <div class="inst-empty" id="inst-empty">None selected — open a group below</div> | |
| <div class="inst-groups" id="inst-groups"></div> | |
| </div> | |
| """ | |
| KNOB_HTML = """ | |
| <div class="knob-wrap" id="tempo-knob-root"> | |
| <div class="knob-face" id="tempo-knob" title="Rotate around the center · scroll to nudge"> | |
| <div class="knob-pointer" id="tempo-pointer"></div> | |
| </div> | |
| <div class="knob-bpm"><span id="tempo-bpm-readout">118</span> BPM</div> | |
| <div class="knob-caption">TEMPO · soft guide (ACE ~30–300)</div> | |
| </div> | |
| """ | |
| MOOD_BOARD_HTML = """ | |
| <div class="mood-board" id="mood-board"> | |
| <div class="mood-faders" id="mood-faders"></div> | |
| <div class="mood-radar-wrap"> | |
| <canvas id="mood-radar" width="148" height="148"></canvas> | |
| </div> | |
| </div> | |
| <div class="mood-derived"> | |
| <div class="mood-chip"><span>MOOD</span><b id="mood-chip-label">—</b></div> | |
| <div class="mood-chip"><span>KEY</span><b id="mood-chip-key">—</b></div> | |
| <div class="mood-chip"><span>METER</span><b id="mood-chip-meter">—</b></div> | |
| </div> | |
| """ | |
| BRAND_HTML = """ | |
| <div class="brand-bar"> | |
| <div> | |
| <div class="booth">INSPIRE</div> | |
| <div class="sub">ACE-Step · Want a faster & more stable experience? Try <a href="https://acemusic.ai" target="_blank" rel="noopener noreferrer">acemusic.ai</a></div> | |
| </div> | |
| <div class="live">DECK READY</div> | |
| </div> | |
| """ | |
| PRESET_HTML = """ | |
| <div class="preset-bar" id="preset-bar"> | |
| <span class="preset-label">PRESETS</span> | |
| <input type="text" id="preset-name" placeholder="Name" maxlength="48" autocomplete="off" /> | |
| <button type="button" id="preset-save">Save</button> | |
| <select id="preset-select" aria-label="Saved presets"></select> | |
| <button type="button" id="preset-load">Load</button> | |
| <button type="button" id="preset-delete">Delete</button> | |
| <span class="preset-status" id="preset-status"></span> | |
| </div> | |
| """ | |
| # Combined client script: rotary tempo + mood board + pad grids | |
| import json | |
| from mood_engine import MOOD_NAMES, resolve_style_bundle | |
| from style_guides import STYLE_GUIDES, style_guide_text | |
| from presets import ( | |
| DEFAULT_DURATION, | |
| DEFAULT_GENRE, | |
| DEFAULT_MODEL, | |
| GENRES, | |
| INSTRUMENT_GROUPS, | |
| MODELS, | |
| STRUCTURES, | |
| USE_CASES, | |
| USE_CASE_BPM, | |
| VOCAL_LANGUAGES, | |
| ) | |
| _AUDIO_FORMATS = ("MP3", "AAC (M4A)", "FLAC", "WAV") | |
| _DEFAULT_AUDIO_FORMAT = "MP3" | |
| def _model_pad_label(name: str) -> str: | |
| if "non-XL" in name: | |
| return "Turbo" | |
| if "XL Turbo" in name: | |
| return "XL Turbo" | |
| if "XL SFT" in name: | |
| return "XL SFT" | |
| if "XL Base" in name: | |
| return "XL Base" | |
| return name | |
| def _format_pad_label(name: str) -> str: | |
| if name.startswith("AAC"): | |
| return "AAC" | |
| return name | |
| def _style_meta(genre: str, style: str) -> dict: | |
| dims_s, _label, _key, _meter, instruments, bpm = resolve_style_bundle(genre, style) | |
| try: | |
| dims = json.loads(dims_s) | |
| except json.JSONDecodeError: | |
| dims = {} | |
| return {"mood_dims": dims, "instruments": instruments, "bpm": bpm} | |
| _PAD_DATA = { | |
| "models": [{"value": k, "label": _model_pad_label(k)} for k in MODELS], | |
| "formats": [{"value": f, "label": _format_pad_label(f)} for f in _AUDIO_FORMATS], | |
| "genres": { | |
| name: { | |
| "styles": g["styles"], | |
| "vocal": g["vocal"], | |
| "instruments": g["instruments"], | |
| "default_bpm": g["default_bpm"], | |
| "bpm": list(g["bpm"]), | |
| "styles_meta": {st: _style_meta(name, st) for st in g["styles"]}, | |
| "style_guides": { | |
| st: STYLE_GUIDES.get(name, {}).get(st) or style_guide_text(name, st) | |
| for st in g["styles"] | |
| }, | |
| } | |
| for name, g in GENRES.items() | |
| }, | |
| "genre_list": list(GENRES.keys()), | |
| "instrument_groups": [{"name": n, "items": items} for n, items in INSTRUMENT_GROUPS], | |
| "use_cases": list(USE_CASES), | |
| "use_case_bpm": {k: v for k, v in USE_CASE_BPM.items()}, | |
| "languages": list(VOCAL_LANGUAGES.keys()), | |
| "structures": list(STRUCTURES.keys()), | |
| "duration_presets": [60, 120, 180, 240, 300, 600], | |
| "defaults": { | |
| "model": DEFAULT_MODEL, | |
| "format": _DEFAULT_AUDIO_FORMAT, | |
| "genre": DEFAULT_GENRE, | |
| "style": GENRES[DEFAULT_GENRE]["styles"][0], | |
| "vocal": GENRES[DEFAULT_GENRE]["vocal"][0], | |
| "use_case": "(none)", | |
| "language": "English", | |
| "structure": list(STRUCTURES.keys())[0], | |
| "duration": DEFAULT_DURATION, | |
| "bpm": GENRES[DEFAULT_GENRE]["default_bpm"], | |
| }, | |
| } | |
| _CONSOLE_JS_TMPL = r""" | |
| () => { | |
| const BPM_MIN = 1; | |
| // no upper clamp — ACE soft-guides ~30–300 but we let the knob run free | |
| const MOOD_NAMES = __MOOD_NAMES__; | |
| const PAD_DATA = __PAD_DATA__; | |
| const MAJOR = ['C major','G major','D major','A major','F major','Bb major','Eb major']; | |
| const MINOR = ['A minor','E minor','D minor','C minor','F minor','G minor','B minor']; | |
| const MINOR_BIAS = new Set(['dark','melancholic','mysterious','aggressive','brooding','gritty','heartbroken','bittersweet','introspective','suspenseful']); | |
| const MAJOR_BIAS = new Set(['uplifting','euphoric','playful','romantic','celebratory','sunny','hopeful','confident','festive','joyful','anthemic','catchy']); | |
| const WALTZISH = new Set(['intimate','tender','sensual','wistful','pastoral','smoky']); | |
| const COMPOUND = new Set(['hypnotic','groovy','peak-time','euphoric','festive']); | |
| let bpm = PAD_DATA.defaults.bpm || 118; | |
| let angle = 0; | |
| let draggingKnob = false; | |
| let lastAngle = null; | |
| let dims = Object.fromEntries(MOOD_NAMES.map(m => [m, 0])); | |
| let lastGenreSeen = ''; | |
| let padsReady = false; | |
| // While the console UI owns tempo/duration, ignore Gradio DOM echoes (stale state). | |
| let consoleOwnedUntil = 0; | |
| const clamp = (v,a,b) => Math.max(a, Math.min(b, v)); | |
| function markConsoleOwned(ms) { | |
| consoleOwnedUntil = Date.now() + (ms == null ? 3000 : ms); | |
| } | |
| function consoleIsOwned() { | |
| return Date.now() < consoleOwnedUntil; | |
| } | |
| function setInput(sel, val) { | |
| const root = document.querySelector(sel); | |
| if (!root) return; | |
| const input = root.querySelector('textarea, input:not([type=checkbox]):not([type=radio]):not([type=range]), select'); | |
| if (!input) return; | |
| const s = String(val); | |
| try { | |
| const proto = input.tagName === 'TEXTAREA' | |
| ? window.HTMLTextAreaElement.prototype | |
| : window.HTMLInputElement.prototype; | |
| const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; | |
| if (setter) setter.call(input, s); | |
| else input.value = s; | |
| } catch (err) { | |
| input.value = s; | |
| } | |
| input.dispatchEvent(new Event('input', { bubbles: true })); | |
| input.dispatchEvent(new Event('change', { bubbles: true })); | |
| try { | |
| input.dispatchEvent(new InputEvent('input', { bubbles: true, data: s, inputType: 'insertFromPaste' })); | |
| } catch (_) {} | |
| } | |
| function fieldRoot(id) { | |
| return document.getElementById(id) || document.querySelector('#' + id); | |
| } | |
| function getTextLike(id) { | |
| const root = fieldRoot(id); | |
| if (!root) return ''; | |
| const input = root.querySelector('textarea, input:not([type=checkbox]):not([type=radio]):not([type=range]), select'); | |
| return input ? input.value : ''; | |
| } | |
| function getNumber(id) { | |
| const v = parseFloat(getTextLike(id)); | |
| return Number.isFinite(v) ? v : null; | |
| } | |
| function getCheckbox(id) { | |
| const root = fieldRoot(id); | |
| if (!root) return false; | |
| const input = root.querySelector('input[type=checkbox]'); | |
| return !!(input && input.checked); | |
| } | |
| function getDropdown(id) { | |
| return getTextLike(id); | |
| } | |
| function getCheckboxGroup(id) { | |
| const root = fieldRoot(id); | |
| if (!root) return []; | |
| return Array.from(root.querySelectorAll('input[type=checkbox]:checked')).map((el) => { | |
| if (el.value && el.value !== 'on') return el.value; | |
| const lab = el.closest('label'); | |
| return (lab ? lab.textContent : '').trim(); | |
| }).filter(Boolean); | |
| } | |
| function getSlider(id) { | |
| const root = fieldRoot(id); | |
| if (!root) return null; | |
| const range = root.querySelector('input[type=range]'); | |
| if (range) { | |
| const v = parseFloat(range.value); | |
| return Number.isFinite(v) ? v : null; | |
| } | |
| return getNumber(id); | |
| } | |
| function setTextLike(id, val) { | |
| setInput('#' + id, val == null ? '' : val); | |
| } | |
| function setCheckbox(id, checked) { | |
| const root = fieldRoot(id); | |
| if (!root) return; | |
| const input = root.querySelector('input[type=checkbox]'); | |
| if (!input) return; | |
| const want = !!checked; | |
| if (input.checked === want) return; | |
| input.click(); | |
| } | |
| function setSlider(id, val) { | |
| if (val == null || !Number.isFinite(Number(val))) return; | |
| const root = fieldRoot(id); | |
| if (!root) return; | |
| const range = root.querySelector('input[type=range]'); | |
| const num = root.querySelector('input[type=number]'); | |
| const target = range || num; | |
| if (!target) { | |
| setTextLike(id, val); | |
| return; | |
| } | |
| const s = String(val); | |
| try { | |
| const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; | |
| if (setter) setter.call(target, s); | |
| else target.value = s; | |
| } catch (_) { | |
| target.value = s; | |
| } | |
| target.dispatchEvent(new Event('input', { bubbles: true })); | |
| target.dispatchEvent(new Event('change', { bubbles: true })); | |
| } | |
| function setCheckboxGroup(id, values) { | |
| const root = fieldRoot(id); | |
| if (!root) return; | |
| const want = new Set((values || []).map(String)); | |
| root.querySelectorAll('input[type=checkbox]').forEach((el) => { | |
| let label = el.value && el.value !== 'on' ? el.value : ''; | |
| if (!label) { | |
| const lab = el.closest('label'); | |
| label = (lab ? lab.textContent : '').trim(); | |
| } | |
| const should = want.has(label); | |
| if (el.checked !== should) el.click(); | |
| }); | |
| if (id === 'instrument-pads') renderInstBrowser(); | |
| } | |
| function getSelectedInstruments() { | |
| return getCheckboxGroup('instrument-pads'); | |
| } | |
| function toggleInstrument(name) { | |
| const cur = new Set(getSelectedInstruments()); | |
| if (cur.has(name)) cur.delete(name); | |
| else cur.add(name); | |
| setCheckboxGroup('instrument-pads', Array.from(cur)); | |
| } | |
| function refreshInstGroupBodies() { | |
| const groupsEl = document.getElementById('inst-groups'); | |
| if (!groupsEl) return; | |
| const selected = new Set(getSelectedInstruments()); | |
| const groups = PAD_DATA.instrument_groups || []; | |
| groupsEl.querySelectorAll('.inst-group').forEach((wrap) => { | |
| const name = wrap.dataset.group; | |
| const g = groups.find((x) => x.name === name); | |
| if (!g) return; | |
| const nSel = g.items.filter((i) => selected.has(i)).length; | |
| const count = wrap.querySelector('.count'); | |
| if (count) count.textContent = nSel ? (nSel + '/' + g.items.length) : String(g.items.length); | |
| const body = wrap.querySelector('.inst-group-body'); | |
| if (!body) return; | |
| if (!wrap.classList.contains('open')) { | |
| body.innerHTML = ''; | |
| return; | |
| } | |
| body.innerHTML = ''; | |
| g.items.forEach((item) => { | |
| const btn = document.createElement('button'); | |
| btn.type = 'button'; | |
| btn.className = 'inst-option' + (selected.has(item) ? ' active' : ''); | |
| btn.textContent = item; | |
| btn.addEventListener('click', () => toggleInstrument(item)); | |
| body.appendChild(btn); | |
| }); | |
| }); | |
| } | |
| function renderInstBrowser() { | |
| const active = document.getElementById('inst-active'); | |
| const empty = document.getElementById('inst-empty'); | |
| const groupsEl = document.getElementById('inst-groups'); | |
| if (!active || !groupsEl) return; | |
| const selected = getSelectedInstruments(); | |
| active.innerHTML = ''; | |
| selected.forEach((name) => { | |
| const btn = document.createElement('button'); | |
| btn.type = 'button'; | |
| btn.className = 'inst-chip'; | |
| btn.innerHTML = '<span></span><span class="x">×</span>'; | |
| btn.querySelector('span').textContent = name; | |
| btn.title = 'Remove ' + name; | |
| btn.addEventListener('click', () => toggleInstrument(name)); | |
| active.appendChild(btn); | |
| }); | |
| if (empty) empty.style.display = selected.length ? 'none' : 'block'; | |
| const groups = PAD_DATA.instrument_groups || []; | |
| if (!groupsEl.dataset.built) { | |
| groupsEl.dataset.built = '1'; | |
| groupsEl.innerHTML = ''; | |
| groups.forEach((g) => { | |
| const wrap = document.createElement('div'); | |
| wrap.className = 'inst-group'; | |
| wrap.dataset.group = g.name; | |
| const head = document.createElement('button'); | |
| head.type = 'button'; | |
| head.className = 'inst-group-head'; | |
| head.innerHTML = '<span class="gname"></span><span class="count"></span><span class="chev">›</span>'; | |
| head.querySelector('.gname').textContent = g.name; | |
| head.addEventListener('click', () => { | |
| const wasOpen = wrap.classList.contains('open'); | |
| groupsEl.querySelectorAll('.inst-group').forEach((el) => el.classList.remove('open')); | |
| if (!wasOpen) wrap.classList.add('open'); | |
| refreshInstGroupBodies(); | |
| }); | |
| const body = document.createElement('div'); | |
| body.className = 'inst-group-body'; | |
| wrap.appendChild(head); | |
| wrap.appendChild(body); | |
| groupsEl.appendChild(wrap); | |
| }); | |
| } | |
| refreshInstGroupBodies(); | |
| } | |
| function bindInstBrowser() { | |
| if (!document.getElementById('inst-browser')) return; | |
| renderInstBrowser(); | |
| } | |
| function topMoods(n) { | |
| return Object.entries(dims) | |
| .sort((a,b) => b[1]-a[1] || a[0].localeCompare(b[0])) | |
| .filter(([,v]) => v > 0) | |
| .slice(0, n); | |
| } | |
| function moodLabel() { | |
| const tops = topMoods(3); | |
| return tops.length ? tops.map(([k]) => k).join(' ') : 'atmospheric'; | |
| } | |
| function deriveKey() { | |
| let minorS = 0, majorS = 0; | |
| for (const [k,v] of Object.entries(dims)) { | |
| if (MINOR_BIAS.has(k)) minorS += v; | |
| if (MAJOR_BIAS.has(k)) majorS += v; | |
| } | |
| const pool = majorS >= minorS ? MAJOR : MINOR; | |
| const warm = (dims.romantic||0) + (dims.sensual||0); | |
| return pool[warm % pool.length]; | |
| } | |
| function deriveMeter() { | |
| let w = 0, c = 0; | |
| for (const [k,v] of Object.entries(dims)) { | |
| if (WALTZISH.has(k)) w += v; | |
| if (COMPOUND.has(k)) c += v; | |
| } | |
| if (w >= 90) return '3'; | |
| if (c >= 140) return '6'; | |
| if ((dims.aggressive||0) >= 70) return '2'; | |
| return '4'; | |
| } | |
| function pushMoodToGradio() { | |
| const label = moodLabel(); | |
| const key = deriveKey(); | |
| const meter = deriveMeter(); | |
| setInput('#mood_dims', JSON.stringify(dims)); | |
| setInput('#mood_label_box', label); | |
| setInput('#key_box', key); | |
| setInput('#meter_box', meter); | |
| const elL = document.getElementById('mood-chip-label'); | |
| const elK = document.getElementById('mood-chip-key'); | |
| const elM = document.getElementById('mood-chip-meter'); | |
| if (elL) elL.textContent = label; | |
| if (elK) elK.textContent = key; | |
| if (elM) elM.textContent = (meter === '6' ? '6/8' : (meter + '/4')); | |
| } | |
| function drawRadar() { | |
| const canvas = document.getElementById('mood-radar'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| const W = canvas.width, H = canvas.height; | |
| const cx = W/2, cy = H/2, R = Math.min(W,H)*0.36; | |
| const tops = topMoods(5); | |
| const axes = tops.length ? tops : [['atmospheric', 40]]; | |
| ctx.clearRect(0,0,W,H); | |
| ctx.strokeStyle = '#2a3142'; | |
| for (let ring = 1; ring <= 4; ring++) { | |
| ctx.beginPath(); | |
| for (let i=0;i<axes.length;i++) { | |
| const a = -Math.PI/2 + i * (Math.PI*2/axes.length); | |
| const r = R * (ring/4); | |
| const x = cx + Math.cos(a)*r, y = cy + Math.sin(a)*r; | |
| if (i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y); | |
| } | |
| ctx.closePath(); | |
| ctx.stroke(); | |
| } | |
| ctx.beginPath(); | |
| ctx.fillStyle = 'rgba(255,176,32,0.18)'; | |
| axes.forEach((ax,i) => { | |
| const a = -Math.PI/2 + i * (Math.PI*2/axes.length); | |
| const r = R * (ax[1]/100); | |
| const x = cx + Math.cos(a)*r, y = cy + Math.sin(a)*r; | |
| if (i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y); | |
| }); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.strokeStyle = '#ffb020'; | |
| ctx.stroke(); | |
| ctx.fillStyle = '#9aa3b2'; | |
| ctx.font = '8px monospace'; | |
| axes.forEach((ax,i) => { | |
| const a = -Math.PI/2 + i * (Math.PI*2/axes.length); | |
| const x = cx + Math.cos(a)*(R+16); | |
| const y = cy + Math.sin(a)*(R+16); | |
| ctx.textAlign = 'center'; | |
| const label = ax[0].length > 8 ? ax[0].slice(0,7)+'…' : ax[0]; | |
| ctx.fillText(label, x, y+3); | |
| }); | |
| } | |
| function paintFader(track, val) { | |
| const fill = track.querySelector('.fill'); | |
| const cap = track.querySelector('.cap'); | |
| if (fill) fill.style.height = `calc(${val}% - 2px)`; | |
| if (cap) cap.style.bottom = `${val}%`; | |
| } | |
| function renderFaders() { | |
| const host = document.getElementById('mood-faders'); | |
| if (!host) return; | |
| if (!host.dataset.ready) { | |
| host.innerHTML = ''; | |
| MOOD_NAMES.forEach((name) => { | |
| const col = document.createElement('div'); | |
| col.className = 'mood-fader'; | |
| col.innerHTML = `<div class="track" data-axis="${name}"><div class="fill"></div><div class="cap"></div></div><div class="ax" title="${name}">${name}</div>`; | |
| host.appendChild(col); | |
| const track = col.querySelector('.track'); | |
| const setFromY = (clientY) => { | |
| const rect = track.getBoundingClientRect(); | |
| const t = clamp(1 - (clientY - rect.top)/rect.height, 0, 1); | |
| dims[name] = Math.round(t*100); | |
| paintFader(track, dims[name]); | |
| drawRadar(); | |
| pushMoodToGradio(); | |
| }; | |
| track.addEventListener('pointerdown', (e) => { | |
| e.preventDefault(); | |
| track.setPointerCapture(e.pointerId); | |
| setFromY(e.clientY); | |
| const move = (ev) => setFromY(ev.clientY); | |
| const up = () => { | |
| track.removeEventListener('pointermove', move); | |
| track.removeEventListener('pointerup', up); | |
| }; | |
| track.addEventListener('pointermove', move); | |
| track.addEventListener('pointerup', up); | |
| }); | |
| }); | |
| host.dataset.ready = '1'; | |
| } | |
| host.querySelectorAll('.track').forEach((track) => { | |
| paintFader(track, dims[track.dataset.axis] || 0); | |
| }); | |
| } | |
| function syncDimsFromGradio() { | |
| const root = document.querySelector('#mood_dims'); | |
| const input = root && root.querySelector('textarea, input'); | |
| if (!input || !input.value) return; | |
| try { | |
| const parsed = JSON.parse(input.value); | |
| let changed = false; | |
| MOOD_NAMES.forEach((k) => { | |
| if (typeof parsed[k] === 'number' && parsed[k] !== dims[k]) { | |
| dims[k] = clamp(parsed[k], 0, 100); | |
| changed = true; | |
| } | |
| }); | |
| if (changed) { | |
| renderFaders(); | |
| drawRadar(); | |
| const label = moodLabel(); | |
| const key = deriveKey(); | |
| const meter = deriveMeter(); | |
| const elL = document.getElementById('mood-chip-label'); | |
| const elK = document.getElementById('mood-chip-key'); | |
| const elM = document.getElementById('mood-chip-meter'); | |
| if (elL) elL.textContent = label; | |
| if (elK) elK.textContent = key; | |
| if (elM) elM.textContent = (meter === '6' ? '6/8' : (meter + '/4')); | |
| setInput('#mood_label_box', label); | |
| setInput('#key_box', key); | |
| setInput('#meter_box', meter); | |
| } | |
| } catch (_) {} | |
| } | |
| function applyMoodPreset(rawDims) { | |
| let parsed = rawDims; | |
| if (typeof rawDims === 'string') { | |
| try { parsed = JSON.parse(rawDims); } catch (_) { return; } | |
| } | |
| if (!parsed || typeof parsed !== 'object') return; | |
| MOOD_NAMES.forEach((k) => { | |
| dims[k] = typeof parsed[k] === 'number' ? clamp(parsed[k], 0, 100) : 0; | |
| }); | |
| renderFaders(); | |
| drawRadar(); | |
| pushMoodToGradio(); | |
| } | |
| function pointerAngle(face, e) { | |
| const r = face.getBoundingClientRect(); | |
| const cx = r.left + r.width/2, cy = r.top + r.height/2; | |
| return Math.atan2(e.clientY - cy, e.clientX - cx); | |
| } | |
| function renderKnob() { | |
| const pointer = document.getElementById('tempo-pointer'); | |
| const readout = document.getElementById('tempo-bpm-readout'); | |
| if (pointer) pointer.style.transform = `rotate(${angle}deg)`; | |
| if (readout) readout.textContent = String(Math.round(bpm)); | |
| } | |
| function setBpm(v, degDelta) { | |
| bpm = Math.max(BPM_MIN, Math.round(v)); | |
| if (typeof degDelta === 'number') angle = (angle + degDelta) % 360; | |
| renderKnob(); | |
| setInput('#bpm_number', bpm); | |
| markConsoleOwned(); | |
| } | |
| function suggestStyleBpm(genre, style) { | |
| const g = PAD_DATA.genres[genre] || PAD_DATA.genres[PAD_DATA.defaults.genre]; | |
| if (!g) return PAD_DATA.defaults.bpm || 118; | |
| const lo = Number(g.bpm[0]); | |
| const hi = Number(g.bpm[1]); | |
| let base = Number(g.default_bpm) || 118; | |
| const s = String(style || '').toLowerCase(); | |
| const slow = ['ballad','doom','ambient','downtempo','study','intimate','quiet','lovers','chill','lo-fi','lofi','roots','delta','solo piano','chamber','baroque','minimalist','meditation','dub']; | |
| const fast = ['dance','club','trance','techno','house','drill','trap','thrash','metalcore','drum and bass','dnb','peak','party','edm','power metal','punk','reggaeton','salsa','afrobeats','future bass','workout']; | |
| const midFast = ['pop','rock','k-pop','synthwave','cumbia','ska','bebop']; | |
| if (slow.some((k) => s.includes(k))) base -= 22; | |
| else if (fast.some((k) => s.includes(k))) base += 18; | |
| else if (midFast.some((k) => s.includes(k))) base += 6; | |
| return Math.max(lo, Math.min(hi, Math.round(base))); | |
| } | |
| function applyUseCaseBpm(useCase) { | |
| const map = PAD_DATA.use_case_bpm || {}; | |
| const v = map[useCase]; | |
| if (v == null || v === undefined) return; | |
| setBpm(Number(v)); | |
| } | |
| function applyStylePreset(genre, style) { | |
| const g = PAD_DATA.genres[genre] || PAD_DATA.genres[PAD_DATA.defaults.genre]; | |
| if (!g) return; | |
| const meta = (g.styles_meta && g.styles_meta[style]) || null; | |
| const bpmVal = meta && meta.bpm != null ? meta.bpm : suggestStyleBpm(genre, style); | |
| setBpm(bpmVal); | |
| if (meta && meta.mood_dims && typeof meta.mood_dims === 'object') { | |
| MOOD_NAMES.forEach((k) => { | |
| const n = Number(meta.mood_dims[k]); | |
| dims[k] = Number.isFinite(n) ? clamp(n, 0, 100) : 0; | |
| }); | |
| renderFaders(); | |
| drawRadar(); | |
| pushMoodToGradio(); | |
| } | |
| if (meta && Array.isArray(meta.instruments) && meta.instruments.length) { | |
| setCheckboxGroup('instrument-pads', meta.instruments); | |
| } else if (Array.isArray(g.instruments) && g.instruments.length) { | |
| setCheckboxGroup('instrument-pads', g.instruments.slice(0, 4)); | |
| } | |
| } | |
| function applyGenreStyleBpm(genre, style) { | |
| applyStylePreset(genre, style); | |
| } | |
| function bindKnob() { | |
| const face = document.getElementById('tempo-knob'); | |
| if (!face || face.dataset.bound === '1') return; | |
| face.dataset.bound = '1'; | |
| face.addEventListener('pointerdown', (e) => { | |
| draggingKnob = true; | |
| lastAngle = pointerAngle(face, e); | |
| face.setPointerCapture(e.pointerId); | |
| }); | |
| face.addEventListener('pointermove', (e) => { | |
| if (!draggingKnob || lastAngle == null) return; | |
| const a = pointerAngle(face, e); | |
| let delta = a - lastAngle; | |
| while (delta > Math.PI) delta -= Math.PI*2; | |
| while (delta < -Math.PI) delta += Math.PI*2; | |
| lastAngle = a; | |
| const deg = delta * 180 / Math.PI; | |
| setBpm(bpm + deg * 0.45, deg); | |
| }); | |
| face.addEventListener('pointerup', () => { draggingKnob = false; lastAngle = null; }); | |
| face.addEventListener('pointercancel', () => { draggingKnob = false; lastAngle = null; }); | |
| face.addEventListener('wheel', (e) => { | |
| e.preventDefault(); | |
| setBpm(bpm - e.deltaY * 0.05, -e.deltaY * 0.2); | |
| }, { passive: false }); | |
| renderKnob(); | |
| } | |
| function bindBpmGradioObserver() { | |
| const root = document.getElementById('bpm_number'); | |
| if (!root || root.dataset.bpmObs === '1') return; | |
| root.dataset.bpmObs = '1'; | |
| new MutationObserver(() => syncBpmFromGradio()).observe(root, { | |
| childList: true, | |
| subtree: true, | |
| characterData: true, | |
| attributes: true, | |
| }); | |
| const input = root.querySelector('input'); | |
| if (input) { | |
| input.addEventListener('input', syncBpmFromGradio); | |
| input.addEventListener('change', syncBpmFromGradio); | |
| } | |
| } | |
| // ── Exclusive pad grids ──────────────────────────────────────────────── | |
| function normalizeOpts(options) { | |
| return (options || []).map((o) => { | |
| if (typeof o === 'string' || typeof o === 'number') { | |
| return { value: String(o), label: String(o) }; | |
| } | |
| return { value: String(o.value), label: String(o.label != null ? o.label : o.value) }; | |
| }); | |
| } | |
| function renderExclusivePads(hostId, options, selected, onSelect, compact) { | |
| const host = document.getElementById(hostId); | |
| if (!host) return; | |
| const opts = normalizeOpts(options); | |
| const sel = selected != null ? String(selected) : ''; | |
| host.innerHTML = ''; | |
| opts.forEach((opt) => { | |
| const btn = document.createElement('button'); | |
| btn.type = 'button'; | |
| btn.className = 'pad' + (compact ? ' compact' : '') + (opt.value === sel ? ' active' : ''); | |
| btn.textContent = opt.label; | |
| btn.title = opt.value; | |
| btn.dataset.value = opt.value; | |
| btn.addEventListener('click', () => { | |
| host.querySelectorAll('.pad').forEach((p) => p.classList.remove('active')); | |
| btn.classList.add('active'); | |
| onSelect(opt.value); | |
| }); | |
| host.appendChild(btn); | |
| }); | |
| } | |
| function markPadActive(hostId, value) { | |
| const host = document.getElementById(hostId); | |
| if (!host) return; | |
| const v = String(value); | |
| host.querySelectorAll('.pad').forEach((p) => { | |
| p.classList.toggle('active', p.dataset.value === v); | |
| }); | |
| } | |
| function updateStyleGuide(genre, style) { | |
| const el = document.getElementById('style-guide-body'); | |
| if (!el) return; | |
| const g = PAD_DATA.genres[genre]; | |
| const text = (g && g.style_guides && g.style_guides[style]) || ''; | |
| el.textContent = text || ( | |
| style | |
| ? (style + ': a ' + genre + ' style. Compare nearby pads to see how feel and production shift within the genre.') | |
| : 'Select a style to see how it differs from others in this genre.' | |
| ); | |
| } | |
| function rebuildStyleVocalForGenre(genre, preferStyle, preferVocal) { | |
| const g = PAD_DATA.genres[genre]; | |
| if (!g) return; | |
| const style = (preferStyle && g.styles.includes(preferStyle)) ? preferStyle : g.styles[0]; | |
| const vocal = (preferVocal && g.vocal.includes(preferVocal)) ? preferVocal : g.vocal[0]; | |
| renderExclusivePads('pads-style', g.styles, style, (v) => { | |
| setTextLike('style', v); | |
| applyGenreStyleBpm(genre, v); | |
| updateStyleGuide(genre, v); | |
| }, true); | |
| renderExclusivePads('pads-vocal', g.vocal, vocal, (v) => setTextLike('vocal', v), true); | |
| updateStyleGuide(genre, style); | |
| } | |
| function formatMmSs(sec) { | |
| const s = Math.max(0, Math.round(Number(sec) || 0)); | |
| const m = Math.floor(s / 60); | |
| const r = s % 60; | |
| return String(m).padStart(2, '0') + ':' + String(r).padStart(2, '0'); | |
| } | |
| // Exponential duration: t=0 → 0s, t=0.5 → 180s, t=1 → 3600s | |
| const DUR_MAX = 3600; | |
| const DUR_GAMMA = Math.log2(20); // 0.5^gamma * 3600 = 180 | |
| let durT = Math.pow(180 / DUR_MAX, 1 / DUR_GAMMA); | |
| let draggingDur = false; | |
| let lastDurAngle = null; | |
| function tToSec(t) { | |
| t = clamp(t, 0, 1); | |
| if (t <= 0) return 0; | |
| return Math.round(DUR_MAX * Math.pow(t, DUR_GAMMA)); | |
| } | |
| function secToT(sec) { | |
| const s = clamp(Number(sec) || 0, 0, DUR_MAX); | |
| if (s <= 0) return 0; | |
| return Math.pow(s / DUR_MAX, 1 / DUR_GAMMA); | |
| } | |
| function durAngleFromT(t) { | |
| // Map t 0..1 onto a 270° pot sweep (-135 .. +135) | |
| return -135 + clamp(t, 0, 1) * 270; | |
| } | |
| function renderDuration() { | |
| const sec = tToSec(durT); | |
| const mmss = document.getElementById('dur-mmss'); | |
| const pointer = document.getElementById('dur-pointer'); | |
| const slider = document.getElementById('dur-slider'); | |
| const bay = document.getElementById('duration-bay'); | |
| const pipelineSec = sec <= 0 ? 10 : Math.max(10, sec); | |
| if (bay) bay.dataset.seconds = String(pipelineSec); | |
| if (mmss) mmss.textContent = formatMmSs(sec); | |
| if (pointer) pointer.style.transform = `rotate(${durAngleFromT(durT)}deg)`; | |
| if (slider) { | |
| const v = Math.round(clamp(durT, 0, 1) * 1000); | |
| if (String(slider.value) !== String(v)) slider.value = String(v); | |
| } | |
| } | |
| function setDuration(sec) { | |
| let n = Math.round(Number(sec)); | |
| if (!Number.isFinite(n)) n = PAD_DATA.defaults.duration; | |
| n = clamp(n, 0, DUR_MAX); | |
| durT = secToT(n); | |
| // Pipeline minimum is 10s; dial can show 00:00 at the left stop | |
| setTextLike('duration', n <= 0 ? 10 : Math.max(10, n)); | |
| markConsoleOwned(); | |
| renderDuration(); | |
| } | |
| function setDurationFromT(t) { | |
| durT = clamp(t, 0, 1); | |
| const sec = tToSec(durT); | |
| setTextLike('duration', sec <= 0 ? 10 : Math.max(10, sec)); | |
| markConsoleOwned(); | |
| renderDuration(); | |
| } | |
| function bindDurationBay() { | |
| const face = document.getElementById('dur-face'); | |
| const slider = document.getElementById('dur-slider'); | |
| if (!face || face.dataset.bound === '1') return; | |
| face.dataset.bound = '1'; | |
| const initial = getNumber('duration'); | |
| setDuration(initial != null ? initial : PAD_DATA.defaults.duration); | |
| face.addEventListener('pointerdown', (e) => { | |
| draggingDur = true; | |
| lastDurAngle = pointerAngle(face, e); | |
| face.setPointerCapture(e.pointerId); | |
| }); | |
| face.addEventListener('pointermove', (e) => { | |
| if (!draggingDur || lastDurAngle == null) return; | |
| const a = pointerAngle(face, e); | |
| let delta = a - lastDurAngle; | |
| while (delta > Math.PI) delta -= Math.PI * 2; | |
| while (delta < -Math.PI) delta += Math.PI * 2; | |
| lastDurAngle = a; | |
| // clockwise increases duration; scale so ~half turn covers useful range | |
| setDurationFromT(durT + delta * 0.35); | |
| }); | |
| face.addEventListener('pointerup', () => { draggingDur = false; lastDurAngle = null; }); | |
| face.addEventListener('pointercancel', () => { draggingDur = false; lastDurAngle = null; }); | |
| face.addEventListener('wheel', (e) => { | |
| e.preventDefault(); | |
| setDurationFromT(durT - e.deltaY * 0.00045); | |
| }, { passive: false }); | |
| if (slider && slider.dataset.bound !== '1') { | |
| slider.dataset.bound = '1'; | |
| slider.addEventListener('input', () => { | |
| setDurationFromT(parseInt(slider.value, 10) / 1000); | |
| }); | |
| } | |
| } | |
| function syncDurationFromGradio() { | |
| // Duration is console-owned (dial). Gradio's hidden Number often lags and must not yank the dial. | |
| // Presets / boot call setDuration() directly. | |
| return; | |
| } | |
| function syncBpmFromGradio() { | |
| // While the knob/pads own tempo, ignore Gradio DOM echoes of stale state. | |
| if (draggingKnob || consoleIsOwned()) return; | |
| const root = document.querySelector('#bpm_number'); | |
| const input = root && root.querySelector('input'); | |
| if (!input) return; | |
| const v = parseFloat(input.value); | |
| if (!Number.isFinite(v)) return; | |
| if (Math.abs(v - bpm) >= 0.5) { | |
| bpm = Math.max(BPM_MIN, Math.round(v)); | |
| renderKnob(); | |
| } | |
| } | |
| function padVal(id, fallback) { | |
| const active = document.querySelector('#' + id + ' .pad.active'); | |
| if (active && active.dataset.value) return active.dataset.value; | |
| return fallback; | |
| } | |
| function liveDurationSec() { | |
| const bay = document.getElementById('duration-bay'); | |
| if (bay && bay.dataset.seconds) { | |
| const n = parseInt(bay.dataset.seconds, 10); | |
| if (Number.isFinite(n) && n >= 10) return Math.min(3600, n); | |
| } | |
| const mmss = document.getElementById('dur-mmss'); | |
| if (mmss) { | |
| const m = String(mmss.textContent || '').trim().match(/^(\d+):(\d{2})$/); | |
| if (m) { | |
| const sec = parseInt(m[1], 10) * 60 + parseInt(m[2], 10); | |
| return Math.max(10, Math.min(3600, sec || 10)); | |
| } | |
| } | |
| const fromDial = tToSec(durT); | |
| return Math.max(10, Math.min(3600, fromDial <= 0 ? 10 : fromDial)); | |
| } | |
| function liveBpmValue() { | |
| const el = document.getElementById('tempo-bpm-readout'); | |
| let live = el ? parseInt(el.textContent, 10) : NaN; | |
| if (!Number.isFinite(live) || live < 1) live = bpm; | |
| return Math.max(1, Math.round(live)); | |
| } | |
| function readLiveConsole() { | |
| const instruments = (() => { | |
| const chips = Array.from(document.querySelectorAll('#inst-active .inst-chip span:first-child')) | |
| .map((el) => (el.textContent || '').trim()) | |
| .filter(Boolean); | |
| if (chips.length) return chips; | |
| return getCheckboxGroup('instrument-pads'); | |
| })(); | |
| const instPad = document.getElementById('pad-instrumental'); | |
| const autoDl = document.getElementById('pad-auto-download'); | |
| const autoPl = document.getElementById('pad-auto-play'); | |
| return { | |
| duration: liveDurationSec(), | |
| bpm: liveBpmValue(), | |
| genre: padVal('pads-genre', getDropdown('genre') || PAD_DATA.defaults.genre), | |
| style: padVal('pads-style', getDropdown('style') || PAD_DATA.defaults.style), | |
| vocal: padVal('pads-vocal', getDropdown('vocal') || PAD_DATA.defaults.vocal), | |
| language: padVal('pads-language', getDropdown('language') || PAD_DATA.defaults.language), | |
| structure: padVal('pads-structure', getDropdown('structure') || PAD_DATA.defaults.structure), | |
| use_case: padVal('pads-usecase', getDropdown('use_case') || PAD_DATA.defaults.use_case), | |
| audio_format: padVal('pads-format', getDropdown('audio_format') || PAD_DATA.defaults.format), | |
| instrumental: instPad ? instPad.dataset.on === '1' : getCheckbox('instrumental'), | |
| auto_download: autoDl ? autoDl.dataset.on === '1' : getCheckbox('auto_download'), | |
| auto_play: autoPl ? autoPl.dataset.on === '1' : getCheckbox('auto_play'), | |
| instruments, | |
| mood: getTextLike('mood_label_box') || '', | |
| keyscale: getTextLike('key_box') || '', | |
| timesignature: getTextLike('meter_box') || '', | |
| theme: getTextLike('inspire_theme') || getTextLike('theme') || '', | |
| instructions: getTextLike('inspire_instructions') || '', | |
| }; | |
| } | |
| // Gradio button js= preprocessors call this (console UI is source of truth at click time). | |
| window.readLiveConsole = readLiveConsole; | |
| window.releaseConsoleOwned = function releaseConsoleOwned() { | |
| consoleOwnedUntil = 0; | |
| }; | |
| window.syncBpmFromGradio = syncBpmFromGradio; | |
| function syncAutoOptPad(padId, checkboxId, onLabel, offLabel) { | |
| const btn = document.getElementById(padId); | |
| if (!btn) return; | |
| const on = getCheckbox(checkboxId); | |
| btn.dataset.on = on ? '1' : '0'; | |
| btn.classList.toggle('active', on); | |
| const label = btn.querySelector('span:not(.auto-led)'); | |
| if (label) label.textContent = on ? onLabel : offLabel; | |
| } | |
| function syncAutoOptsPads() { | |
| syncAutoOptPad('pad-auto-download', 'auto_download', 'Download auto · on', 'Download auto'); | |
| syncAutoOptPad('pad-auto-play', 'auto_play', 'Play auto · on', 'Play auto'); | |
| } | |
| function bindAutoOptsPads() { | |
| const pairs = [ | |
| ['pad-auto-download', 'auto_download'], | |
| ['pad-auto-play', 'auto_play'], | |
| ]; | |
| pairs.forEach(([padId, checkboxId]) => { | |
| const btn = document.getElementById(padId); | |
| if (!btn || btn.dataset.bound === '1') return; | |
| btn.dataset.bound = '1'; | |
| btn.addEventListener('click', () => { | |
| const next = btn.dataset.on !== '1'; | |
| setCheckbox(checkboxId, next); | |
| syncAutoOptsPads(); | |
| }); | |
| }); | |
| syncAutoOptsPads(); | |
| } | |
| function syncInstrumentalPad() { | |
| const btn = document.getElementById('pad-instrumental'); | |
| if (!btn) return; | |
| const on = getCheckbox('instrumental'); | |
| btn.dataset.on = on ? '1' : '0'; | |
| btn.classList.toggle('active', on); | |
| btn.textContent = on ? 'Instrumental · on' : 'Instrumental · off'; | |
| } | |
| function bindInstrumentalPad() { | |
| const btn = document.getElementById('pad-instrumental'); | |
| if (!btn || btn.dataset.bound === '1') return; | |
| btn.dataset.bound = '1'; | |
| btn.addEventListener('click', () => { | |
| const next = btn.dataset.on !== '1'; | |
| setCheckbox('instrumental', next); | |
| syncInstrumentalPad(); | |
| }); | |
| syncInstrumentalPad(); | |
| } | |
| function bindAllPads() { | |
| if (!document.getElementById('pads-format')) return; | |
| if (!padsReady) { | |
| renderExclusivePads('pads-format', PAD_DATA.formats, getDropdown('audio_format') || PAD_DATA.defaults.format, (v) => setTextLike('audio_format', v), true); | |
| renderExclusivePads('pads-genre', PAD_DATA.genre_list, getDropdown('genre') || PAD_DATA.defaults.genre, (v) => { | |
| setTextLike('genre', v); | |
| lastGenreSeen = v; | |
| rebuildStyleVocalForGenre(v); | |
| const g = PAD_DATA.genres[v]; | |
| const st = g ? g.styles[0] : ''; | |
| if (g) setTextLike('style', st); | |
| if (g) setTextLike('vocal', g.vocal[0]); | |
| applyGenreStyleBpm(v, st); | |
| }, true); | |
| renderExclusivePads('pads-usecase', PAD_DATA.use_cases, getDropdown('use_case') || PAD_DATA.defaults.use_case, (v) => { | |
| setTextLike('use_case', v); | |
| applyUseCaseBpm(v); | |
| }, true); | |
| renderExclusivePads('pads-language', PAD_DATA.languages, getDropdown('language') || PAD_DATA.defaults.language, (v) => setTextLike('language', v), true); | |
| renderExclusivePads('pads-structure', PAD_DATA.structures, getDropdown('structure') || PAD_DATA.defaults.structure, (v) => setTextLike('structure', v), true); | |
| const g0 = getDropdown('genre') || PAD_DATA.defaults.genre; | |
| lastGenreSeen = g0; | |
| rebuildStyleVocalForGenre(g0, getDropdown('style'), getDropdown('vocal')); | |
| bindDurationBay(); | |
| bindInstrumentalPad(); | |
| bindAutoOptsPads(); | |
| bindInstBrowser(); | |
| padsReady = true; | |
| } | |
| } | |
| function refreshPadsFromGradio() { | |
| if (!padsReady) return; | |
| const genre = getDropdown('genre'); | |
| if (genre && genre !== lastGenreSeen) { | |
| lastGenreSeen = genre; | |
| rebuildStyleVocalForGenre(genre, getDropdown('style'), getDropdown('vocal')); | |
| markPadActive('pads-genre', genre); | |
| } else { | |
| markPadActive('pads-genre', genre || PAD_DATA.defaults.genre); | |
| markPadActive('pads-style', getDropdown('style')); | |
| markPadActive('pads-vocal', getDropdown('vocal')); | |
| updateStyleGuide(genre || PAD_DATA.defaults.genre, getDropdown('style') || PAD_DATA.defaults.style); | |
| } | |
| markPadActive('pads-format', getDropdown('audio_format')); | |
| markPadActive('pads-usecase', getDropdown('use_case')); | |
| markPadActive('pads-language', getDropdown('language')); | |
| markPadActive('pads-structure', getDropdown('structure')); | |
| syncDurationFromGradio(); | |
| syncInstrumentalPad(); | |
| syncAutoOptsPads(); | |
| renderInstBrowser(); | |
| } | |
| // ── localStorage presets ─────────────────────────────────────────────── | |
| const PRESET_KEY = 'ace-inspire-presets-v1'; | |
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| function collectPreset() { | |
| let moodDimsRaw = getTextLike('mood_dims'); | |
| let moodDimsObj = dims; | |
| try { | |
| if (moodDimsRaw) moodDimsObj = JSON.parse(moodDimsRaw); | |
| } catch (_) {} | |
| const liveBpmEl = document.getElementById('tempo-bpm-readout'); | |
| const liveBpm = liveBpmEl ? parseInt(liveBpmEl.textContent, 10) : getNumber('bpm_number'); | |
| return { | |
| v: 1, | |
| savedAt: new Date().toISOString(), | |
| model: getDropdown('model'), | |
| duration: getNumber('duration'), | |
| steps: getSlider('steps'), | |
| guidance: getSlider('guidance'), | |
| shift: getSlider('shift'), | |
| seed: getNumber('seed'), | |
| random_seed: getCheckbox('random_seed'), | |
| lyric_seed: getNumber('lyric_seed'), | |
| genre: getDropdown('genre'), | |
| style: getDropdown('style'), | |
| use_case: getDropdown('use_case'), | |
| theme: getTextLike('inspire_theme') || getTextLike('theme'), | |
| instructions: getTextLike('inspire_instructions') || '', | |
| vocal: getDropdown('vocal'), | |
| mood_dims: moodDimsObj, | |
| mood: getTextLike('mood_label_box'), | |
| keyscale: getTextLike('key_box'), | |
| timesignature: getTextLike('meter_box'), | |
| instruments: getCheckboxGroup('instrument-pads'), | |
| bpm: Number.isFinite(liveBpm) ? liveBpm : getNumber('bpm_number'), | |
| language: getDropdown('language'), | |
| structure: getDropdown('structure'), | |
| instrumental: getCheckbox('instrumental'), | |
| lyrics: getTextLike('lyrics'), | |
| audio_format: getDropdown('audio_format'), | |
| }; | |
| } | |
| function readStore() { | |
| try { | |
| const raw = localStorage.getItem(PRESET_KEY); | |
| const data = raw ? JSON.parse(raw) : {}; | |
| return data && typeof data === 'object' ? data : {}; | |
| } catch (_) { | |
| return {}; | |
| } | |
| } | |
| function writeStore(obj) { | |
| localStorage.setItem(PRESET_KEY, JSON.stringify(obj)); | |
| } | |
| function presetStatus(msg, ok) { | |
| const el = document.getElementById('preset-status'); | |
| if (!el) return; | |
| el.textContent = msg || ''; | |
| el.style.color = ok === false ? 'var(--danger)' : 'var(--led)'; | |
| } | |
| function refreshPresetSelect(selected) { | |
| const sel = document.getElementById('preset-select'); | |
| if (!sel) return; | |
| const store = readStore(); | |
| const names = Object.keys(store).sort((a, b) => a.localeCompare(b)); | |
| const cur = selected != null ? selected : sel.value; | |
| sel.innerHTML = ''; | |
| const ph = document.createElement('option'); | |
| ph.value = ''; | |
| ph.textContent = names.length ? '— choose —' : '— none saved —'; | |
| sel.appendChild(ph); | |
| names.forEach((name) => { | |
| const opt = document.createElement('option'); | |
| opt.value = name; | |
| opt.textContent = name; | |
| sel.appendChild(opt); | |
| }); | |
| if (cur && store[cur]) sel.value = cur; | |
| } | |
| async function applyPreset(p) { | |
| if (!p || typeof p !== 'object') return; | |
| if (p.model != null) { | |
| setTextLike('model', p.model); | |
| await sleep(450); | |
| } | |
| if (p.duration != null) setDuration(p.duration); | |
| if (p.audio_format != null) setTextLike('audio_format', p.audio_format); | |
| if (p.steps != null) setSlider('steps', p.steps); | |
| if (p.guidance != null) setSlider('guidance', p.guidance); | |
| if (p.shift != null) setSlider('shift', p.shift); | |
| if (p.seed != null) setTextLike('seed', p.seed); | |
| if (p.lyric_seed != null) setTextLike('lyric_seed', p.lyric_seed); | |
| if (typeof p.random_seed === 'boolean') setCheckbox('random_seed', p.random_seed); | |
| if (p.use_case != null) setTextLike('use_case', p.use_case); | |
| if (p.theme != null) { | |
| setTextLike('inspire_theme', p.theme); | |
| setTextLike('theme', p.theme); | |
| } | |
| if (p.instructions != null) setTextLike('inspire_instructions', p.instructions); | |
| if (p.language != null) setTextLike('language', p.language); | |
| if (p.structure != null) setTextLike('structure', p.structure); | |
| if (typeof p.instrumental === 'boolean') setCheckbox('instrumental', p.instrumental); | |
| if (p.lyrics != null) setTextLike('lyrics', p.lyrics); | |
| if (p.genre != null) { | |
| setTextLike('genre', p.genre); | |
| lastGenreSeen = p.genre; | |
| rebuildStyleVocalForGenre(p.genre, p.style, p.vocal); | |
| await sleep(450); | |
| } | |
| if (p.style != null) setTextLike('style', p.style); | |
| if (p.vocal != null) setTextLike('vocal', p.vocal); | |
| if (Array.isArray(p.instruments)) { | |
| await sleep(120); | |
| setCheckboxGroup('instrument-pads', p.instruments); | |
| } | |
| if (p.bpm != null) setBpm(Number(p.bpm)); | |
| if (p.mood_dims) applyMoodPreset(p.mood_dims); | |
| if (p.mood) setTextLike('mood_label_box', p.mood); | |
| if (p.keyscale) setTextLike('key_box', p.keyscale); | |
| if (p.timesignature) setTextLike('meter_box', p.timesignature); | |
| if (p.keyscale) { | |
| const elK = document.getElementById('mood-chip-key'); | |
| if (elK) elK.textContent = p.keyscale; | |
| } | |
| if (p.timesignature) { | |
| const elM = document.getElementById('mood-chip-meter'); | |
| const m = String(p.timesignature); | |
| if (elM) elM.textContent = (m === '6' ? '6/8' : (m + '/4')); | |
| } | |
| if (p.mood) { | |
| const elL = document.getElementById('mood-chip-label'); | |
| if (elL) elL.textContent = p.mood; | |
| } | |
| refreshPadsFromGradio(); | |
| } | |
| function bindPresets() { | |
| const bar = document.getElementById('preset-bar'); | |
| if (!bar || bar.dataset.bound === '1') return; | |
| bar.dataset.bound = '1'; | |
| refreshPresetSelect(); | |
| document.getElementById('preset-save')?.addEventListener('click', () => { | |
| const nameEl = document.getElementById('preset-name'); | |
| const name = (nameEl?.value || document.getElementById('preset-select')?.value || '').trim(); | |
| if (!name) { | |
| presetStatus('name required', false); | |
| return; | |
| } | |
| const store = readStore(); | |
| store[name] = collectPreset(); | |
| writeStore(store); | |
| if (nameEl) nameEl.value = name; | |
| refreshPresetSelect(name); | |
| presetStatus('saved', true); | |
| }); | |
| document.getElementById('preset-load')?.addEventListener('click', async () => { | |
| const name = document.getElementById('preset-select')?.value; | |
| if (!name) { | |
| presetStatus('pick one', false); | |
| return; | |
| } | |
| const store = readStore(); | |
| const p = store[name]; | |
| if (!p) { | |
| presetStatus('missing', false); | |
| return; | |
| } | |
| presetStatus('loading…', true); | |
| try { | |
| await applyPreset(p); | |
| const nameEl = document.getElementById('preset-name'); | |
| if (nameEl) nameEl.value = name; | |
| presetStatus('loaded', true); | |
| } catch (err) { | |
| console.error(err); | |
| presetStatus('load failed', false); | |
| } | |
| }); | |
| document.getElementById('preset-delete')?.addEventListener('click', () => { | |
| const name = document.getElementById('preset-select')?.value; | |
| if (!name) { | |
| presetStatus('pick one', false); | |
| return; | |
| } | |
| const store = readStore(); | |
| if (!store[name]) { | |
| presetStatus('missing', false); | |
| return; | |
| } | |
| delete store[name]; | |
| writeStore(store); | |
| refreshPresetSelect(''); | |
| const nameEl = document.getElementById('preset-name'); | |
| if (nameEl && nameEl.value.trim() === name) nameEl.value = ''; | |
| presetStatus('deleted', true); | |
| }); | |
| } | |
| function syncGenerateHeight() { | |
| const row = document.querySelector('.deck-shell-row'); | |
| const deck = document.querySelector('.deck-shell-row .deck-panel, #deck-panel'); | |
| const gen = document.querySelector('.deck-shell-row .deck-gen-col'); | |
| const btn = document.querySelector('#generate-song-btn'); | |
| if (!row || !deck || !gen || !btn) return; | |
| if (!btn.dataset.stacked) { | |
| btn.dataset.stacked = '1'; | |
| btn.innerHTML = 'Generate<br>song'; | |
| } | |
| const h = Math.ceil(deck.getBoundingClientRect().height); | |
| if (h > 0) { | |
| gen.style.height = h + 'px'; | |
| btn.style.height = '100%'; | |
| } | |
| } | |
| function boot() { | |
| bindKnob(); | |
| bindBpmGradioObserver(); | |
| bindPresets(); | |
| bindAllPads(); | |
| syncGenerateHeight(); | |
| const deck = document.querySelector('.deck-shell-row .deck-panel, #deck-panel'); | |
| if (deck && !deck.dataset.heightObs) { | |
| deck.dataset.heightObs = '1'; | |
| if (window.ResizeObserver) { | |
| new ResizeObserver(() => syncGenerateHeight()).observe(deck); | |
| } | |
| } | |
| const root = document.querySelector('#mood_dims'); | |
| const input = root && root.querySelector('textarea, input'); | |
| if (input && input.value) { | |
| try { | |
| const parsed = JSON.parse(input.value); | |
| MOOD_NAMES.forEach((k) => { | |
| if (typeof parsed[k] === 'number') dims[k] = clamp(parsed[k], 0, 100); | |
| }); | |
| } catch (_) {} | |
| } | |
| renderFaders(); | |
| drawRadar(); | |
| pushMoodToGradio(); | |
| } | |
| setInterval(() => { | |
| bindKnob(); | |
| bindBpmGradioObserver(); | |
| bindPresets(); | |
| bindAllPads(); | |
| refreshPadsFromGradio(); | |
| syncGenerateHeight(); | |
| syncBpmFromGradio(); | |
| syncDimsFromGradio(); | |
| if (!document.getElementById('mood-faders')?.dataset.ready) { | |
| renderFaders(); | |
| drawRadar(); | |
| } | |
| }, 400); | |
| boot(); | |
| } | |
| """ | |
| CONSOLE_JS = ( | |
| _CONSOLE_JS_TMPL | |
| .replace("__MOOD_NAMES__", json.dumps(list(MOOD_NAMES))) | |
| .replace("__PAD_DATA__", json.dumps(_PAD_DATA)) | |
| ) | |