techfreakworm commited on
Commit
a1d8cb5
·
unverified ·
1 Parent(s): 96012ce

feat(ui): wire single-lora picker into generate tab (m2 d5)

Browse files

Adds a collapsed-by-default LoRA accordion in the Generate tab between
the vocal-mode row and the Generate button. Inside:
- Preset radio (4 bundled + None) — rendered as pill-shaped chips
- Custom .safetensors upload with a dashed drop zone
- Strength slider (0.0-1.5, default 0.95)
- Active LoRA display (Markdown showing name + scale + kind)
- Hidden gr.State carrying the resolved {name, scale, path, sha256} dict

Picking a preset triggers lora_stack.download_preset + sniff + sets
state and clears the upload. Uploading a custom file triggers sniff +
sets state and resets the preset to "None". The Generate button passes
the state to backend.dispatch as params["loras"] = [state] (or [] when
None).

Reflects the apple-silicon fork's single-LoRA reality — a multi-row
stack is deferred until upstream adds multi-adapter support.

Brutalist Mono / IBM Plex chrome preserved via elem_classes hooks and
matching CSS in theme.py. Specificity bumped on the preset-radio
selector so it beats the generic Vocal-mode .wrap rule (which would
otherwise force a column layout). Verified visually at 360 px and
1440 px via Playwright; no regression vs the prior M1 visual.

Files changed (3) hide show
  1. app.py +124 -2
  2. theme.py +225 -2
  3. ui.py +61 -2
app.py CHANGED
@@ -43,12 +43,15 @@ os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
43
  # Don't pin HF download source — let HF default for both Spaces and local cache.
44
  os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
45
 
 
46
  import random
 
47
 
48
  import gradio as gr
49
 
50
  import ace_pipeline
51
  import backend as be
 
52
  import modes
53
  import theme
54
  import ui
@@ -63,13 +66,111 @@ def get_backend() -> be.ACEStepStudioBackend:
63
  return _BACKEND
64
 
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  def on_generate_click(
67
  prompt: str,
68
  lyrics: str,
69
  duration_s: float,
70
  instrumental_label: str,
 
71
  progress=gr.Progress(track_tqdm=True), # noqa: B008
72
  ):
 
73
  try:
74
  out_path, meta = modes.generate(
75
  get_backend(),
@@ -79,7 +180,7 @@ def on_generate_click(
79
  "duration_s": int(duration_s),
80
  "instrumental": instrumental_label == "Instrumental",
81
  "seed": random.randint(1, 2_147_483_647),
82
- "loras": [],
83
  "advanced": {},
84
  "lm": {},
85
  "dcw": {},
@@ -172,9 +273,30 @@ def build_app() -> gr.Blocks:
172
  with gr.Column(scale=10, elem_classes=["ams-content"]):
173
  with gr.Group(visible=True, elem_classes=["ams-tab-pane"]) as pane_generate:
174
  g = ui.build_generate_tab()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  g["generate_btn"].click(
176
  fn=on_generate_click,
177
- inputs=[g["prompt"], g["lyrics"], g["duration_s"], g["instrumental"]],
 
 
 
 
 
 
178
  outputs=[g["output_audio"], g["output_meta"]],
179
  )
180
  with gr.Group(visible=False, elem_classes=["ams-tab-pane"]) as pane_cover:
 
43
  # Don't pin HF download source — let HF default for both Spaces and local cache.
44
  os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
45
 
46
+ import hashlib
47
  import random
48
+ from pathlib import Path
49
 
50
  import gradio as gr
51
 
52
  import ace_pipeline
53
  import backend as be
54
+ import lora_stack
55
  import modes
56
  import theme
57
  import ui
 
66
  return _BACKEND
67
 
68
 
69
+ def _sha256(path: str) -> str:
70
+ """Stream a file through SHA-256 in 64 KB chunks.
71
+
72
+ Used to fingerprint the active LoRA so the generation metadata
73
+ includes a provenance hash (useful when the user uploads variants
74
+ of the same psytrance fine-tune with subtly different weights).
75
+ """
76
+ h = hashlib.sha256()
77
+ with open(path, "rb") as f:
78
+ for chunk in iter(lambda: f.read(65536), b""):
79
+ h.update(chunk)
80
+ return h.hexdigest()
81
+
82
+
83
+ def _active_md(name: str, scale: float, kind: str) -> str:
84
+ """Format the 'Active: …' line shown under the strength slider."""
85
+ return f"**Active:** `{name}`  ·  scale `{scale:.2f}`  ·  {kind}"
86
+
87
+
88
+ def on_lora_preset_change(preset_name: str, strength: float):
89
+ """User picked a preset (or 'None'). Downloads + validates + sets state.
90
+
91
+ Returns (state, active_markdown, upload_clear_value) — the third
92
+ value clears any custom-upload widget so the two inputs stay
93
+ mutually exclusive.
94
+ """
95
+ if preset_name == "None" or not preset_name:
96
+ return None, "_No LoRA active_", None
97
+
98
+ try:
99
+ local_path = lora_stack.download_preset(preset_name)
100
+ except lora_stack.LoRAValidationError as e:
101
+ raise gr.Error(str(e)) from e
102
+
103
+ info = lora_stack.sniff(local_path)
104
+ if not info.compatible:
105
+ raise gr.Error(
106
+ f"Preset {preset_name!r} is not compatible with ACE-Step 1.5 XL SFT: {info.diagnostic}"
107
+ )
108
+
109
+ state = {
110
+ "name": preset_name,
111
+ "scale": float(strength),
112
+ "path": str(local_path),
113
+ "sha256": _sha256(str(local_path)),
114
+ }
115
+ return state, _active_md(preset_name, float(strength), "preset"), None
116
+
117
+
118
+ def on_lora_upload(file_obj, strength: float):
119
+ """User dropped a custom .safetensors. Replaces any active preset.
120
+
121
+ Returns (state, active_markdown, preset_reset_value) — the third
122
+ value resets the preset radio to 'None' so the two inputs stay
123
+ mutually exclusive.
124
+ """
125
+ if file_obj is None:
126
+ return None, "_No LoRA active_", "None"
127
+
128
+ path_str = file_obj.name if hasattr(file_obj, "name") else str(file_obj)
129
+ try:
130
+ info = lora_stack.sniff(path_str)
131
+ except lora_stack.LoRAValidationError as e:
132
+ raise gr.Error(str(e)) from e
133
+
134
+ if not info.compatible:
135
+ raise gr.Error(f"Uploaded LoRA isn't compatible with ACE-Step 1.5 XL SFT: {info.diagnostic}")
136
+
137
+ name = Path(path_str).stem
138
+ state = {
139
+ "name": name,
140
+ "scale": float(strength),
141
+ "path": path_str,
142
+ "sha256": _sha256(path_str),
143
+ }
144
+ return state, _active_md(name, float(strength), "custom"), "None"
145
+
146
+
147
+ def on_lora_strength_change(state, strength: float):
148
+ """User dragged the strength slider. Update scale on the active LoRA.
149
+
150
+ No-op if no LoRA is active.
151
+ """
152
+ if not state:
153
+ return state, "_No LoRA active_"
154
+ new_state = {**state, "scale": float(strength)}
155
+ # Preserve the "preset" vs "custom" tag — presets resolve to a path
156
+ # under the HF cache (~/.cache/huggingface/hub/…), uploads land
157
+ # under /tmp/gradio/… or the user's pwd. Use the same heuristic
158
+ # the upload/preset handlers used: a path inside the HF cache or
159
+ # snapshot tree counts as preset, otherwise custom.
160
+ path = str(new_state.get("path", ""))
161
+ kind = "preset" if (".cache/huggingface" in path or "snapshots" in path) else "custom"
162
+ return new_state, _active_md(new_state["name"], float(strength), kind)
163
+
164
+
165
  def on_generate_click(
166
  prompt: str,
167
  lyrics: str,
168
  duration_s: float,
169
  instrumental_label: str,
170
+ lora_state,
171
  progress=gr.Progress(track_tqdm=True), # noqa: B008
172
  ):
173
+ loras = [lora_state] if lora_state else []
174
  try:
175
  out_path, meta = modes.generate(
176
  get_backend(),
 
180
  "duration_s": int(duration_s),
181
  "instrumental": instrumental_label == "Instrumental",
182
  "seed": random.randint(1, 2_147_483_647),
183
+ "loras": loras,
184
  "advanced": {},
185
  "lm": {},
186
  "dcw": {},
 
273
  with gr.Column(scale=10, elem_classes=["ams-content"]):
274
  with gr.Group(visible=True, elem_classes=["ams-tab-pane"]) as pane_generate:
275
  g = ui.build_generate_tab()
276
+ g["lora_preset"].change(
277
+ fn=on_lora_preset_change,
278
+ inputs=[g["lora_preset"], g["lora_strength"]],
279
+ outputs=[g["lora_state"], g["lora_active"], g["lora_upload"]],
280
+ )
281
+ g["lora_upload"].change(
282
+ fn=on_lora_upload,
283
+ inputs=[g["lora_upload"], g["lora_strength"]],
284
+ outputs=[g["lora_state"], g["lora_active"], g["lora_preset"]],
285
+ )
286
+ g["lora_strength"].change(
287
+ fn=on_lora_strength_change,
288
+ inputs=[g["lora_state"], g["lora_strength"]],
289
+ outputs=[g["lora_state"], g["lora_active"]],
290
+ )
291
  g["generate_btn"].click(
292
  fn=on_generate_click,
293
+ inputs=[
294
+ g["prompt"],
295
+ g["lyrics"],
296
+ g["duration_s"],
297
+ g["instrumental"],
298
+ g["lora_state"],
299
+ ],
300
  outputs=[g["output_audio"], g["output_meta"]],
301
  )
302
  with gr.Group(visible=False, elem_classes=["ams-tab-pane"]) as pane_cover:
theme.py CHANGED
@@ -552,7 +552,7 @@ main, .contain {{
552
  }}
553
 
554
  /* ============================================================
555
- * LoRA chip pill — kept for M2 wiring
556
  * ============================================================ */
557
  .ams-chip {{
558
  display:inline-block; padding:5px 10px; border-radius:14px;
@@ -561,7 +561,230 @@ main, .contain {{
561
  }}
562
  .ams-chip.on {{ border-color:{PRIMARY}; color:{PRIMARY}; }}
563
  .ams-chip.upload {{ border-style:dashed; color:{PRIMARY}; }}
564
- .ams-lora-file .upload-container {{ min-height:56px !important; }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
 
566
  /* Hide Gradio footer + the floating "Use via API" / settings panel */
567
  footer {{ display:none !important; }}
 
552
  }}
553
 
554
  /* ============================================================
555
+ * LoRA chip pill — kept for legacy use (chip-style hooks elsewhere)
556
  * ============================================================ */
557
  .ams-chip {{
558
  display:inline-block; padding:5px 10px; border-radius:14px;
 
561
  }}
562
  .ams-chip.on {{ border-color:{PRIMARY}; color:{PRIMARY}; }}
563
  .ams-chip.upload {{ border-style:dashed; color:{PRIMARY}; }}
564
+
565
+ /* ============================================================
566
+ * LoRA accordion (D5)
567
+ * The collapsed accordion sits between the duration/vocal-mode row
568
+ * and the Generate button. Inside: a note, preset radio, custom
569
+ * upload, strength slider, and an "Active: …" Markdown line.
570
+ * The outer chrome matches the wireframe's bordered section header.
571
+ * ============================================================ */
572
+ .ams-content .ams-lora {{
573
+ border:1px solid {BORDER} !important;
574
+ border-radius:3px !important;
575
+ background:{SURFACE_STRONG} !important;
576
+ margin-top:10px !important;
577
+ padding:0 !important;
578
+ }}
579
+ /* Accordion summary / label. Gradio 6.14 renders this as either
580
+ .label-wrap (older builds) or a native <summary> element. Style
581
+ both so the uppercase-mono header is consistent. */
582
+ .ams-content .ams-lora > .label-wrap,
583
+ .ams-content .ams-lora summary,
584
+ .ams-content .ams-lora > button {{
585
+ font-family: {FONT_MONO} !important;
586
+ font-size:10px !important;
587
+ letter-spacing:0.08em !important;
588
+ text-transform:uppercase !important;
589
+ color:{INK_MUTED} !important;
590
+ padding:10px 12px !important;
591
+ background:transparent !important;
592
+ border:none !important;
593
+ }}
594
+ .ams-content .ams-lora > .label-wrap span,
595
+ .ams-content .ams-lora summary span,
596
+ .ams-content .ams-lora > button span {{
597
+ color:{INK_MUTED} !important;
598
+ font-family: {FONT_MONO} !important;
599
+ font-size:10px !important;
600
+ letter-spacing:0.08em !important;
601
+ text-transform:uppercase !important;
602
+ }}
603
+ /* Italic note under the header */
604
+ .ams-content .ams-lora-note p {{
605
+ font-family: {FONT_SANS} !important;
606
+ font-size:10px !important;
607
+ font-style:italic !important;
608
+ color:{INK_FAINT} !important;
609
+ line-height:1.4 !important;
610
+ margin:0 0 10px 0 !important;
611
+ padding:0 12px !important;
612
+ }}
613
+ /* The expanded body padding — Gradio drops the children inside
614
+ .gap (or unnamed wrapper) directly. Use a left/right padding so
615
+ the radio + file + slider don't hug the border. */
616
+ .ams-content .ams-lora > div:not(.label-wrap):not(summary) {{
617
+ padding:0 12px 12px 12px !important;
618
+ }}
619
+ /* Preset radio: row of compact pills. Gradio renders the radio body
620
+ as ``fieldset.ams-lora-preset > div.wrap.svelte-e4x47i > label*``.
621
+ The generic Vocal-mode rule
622
+ .ams-content .block:has(input[type="radio"]) .wrap
623
+ computes specificity (0,4,1) — three classes + the inner attribute
624
+ selector via :has. To beat that we chain ``.ams-content .ams-lora
625
+ .ams-lora-preset.ams-lora-preset > .wrap`` which is (0,5,0), winning
626
+ by one class. */
627
+ .ams-content .ams-lora .ams-lora-preset.ams-lora-preset > .wrap {{
628
+ display:flex !important;
629
+ flex-direction:row !important;
630
+ flex-wrap:wrap !important;
631
+ gap:6px !important;
632
+ background:transparent !important;
633
+ border:none !important;
634
+ padding:0 !important;
635
+ width:100% !important;
636
+ }}
637
+ .ams-content .ams-lora .ams-lora-preset.ams-lora-preset > .wrap > label {{
638
+ flex:0 0 auto !important;
639
+ width:auto !important;
640
+ max-width:max-content !important;
641
+ min-width:0 !important;
642
+ background:#000 !important;
643
+ border:1px solid {BORDER} !important;
644
+ border-radius:14px !important;
645
+ padding:5px 12px !important;
646
+ font-size:11px !important;
647
+ color:{INK_MUTED} !important;
648
+ font-weight:500 !important;
649
+ display:inline-flex !important;
650
+ align-items:center !important;
651
+ gap:0 !important;
652
+ cursor:pointer !important;
653
+ }}
654
+ .ams-content .ams-lora .ams-lora-preset.ams-lora-preset > .wrap > label:hover {{
655
+ color:{INK} !important;
656
+ border-color:{BORDER_STRONG} !important;
657
+ }}
658
+ .ams-content .ams-lora .ams-lora-preset.ams-lora-preset > .wrap > label:has(input[type="radio"]:checked) {{
659
+ border-color:{PRIMARY} !important;
660
+ color:{PRIMARY} !important;
661
+ background:#0F0F0F !important;
662
+ }}
663
+ /* Hide the inner radio-dot input; the pill border + color carries
664
+ the on/off state on its own. */
665
+ .ams-content .ams-lora .ams-lora-preset.ams-lora-preset > .wrap > label input[type="radio"] {{
666
+ display:none !important;
667
+ width:0 !important; height:0 !important;
668
+ margin:0 !important; padding:0 !important;
669
+ background:none !important;
670
+ border:none !important;
671
+ }}
672
+ .ams-content .ams-lora .ams-lora-preset.ams-lora-preset > .wrap > label span {{
673
+ text-transform:none !important;
674
+ letter-spacing:0 !important;
675
+ font-family: {FONT_SANS} !important;
676
+ font-size:11px !important;
677
+ color:inherit !important;
678
+ font-weight:500 !important;
679
+ }}
680
+
681
+ /* Custom-upload file widget. Gradio 6.14 renders the drop-zone as
682
+ ``button.svelte-8prmba`` (NOT ``.upload-container``). The label
683
+ above it is ``label.svelte-19djge9.float`` which carries the
684
+ uploaded-file metadata once a file is dropped. Style the actual
685
+ drop-button and override the legacy ``.upload-container`` rule
686
+ from above for forward compatibility. */
687
+ .ams-content .ams-lora-file > button {{
688
+ min-height:80px !important;
689
+ background:#000 !important;
690
+ border:1px dashed {BORDER_STRONG} !important;
691
+ border-radius:3px !important;
692
+ color:{INK_MUTED} !important;
693
+ padding:14px 12px !important;
694
+ }}
695
+ .ams-content .ams-lora-file > button:hover {{
696
+ border-color:{PRIMARY} !important;
697
+ color:{INK} !important;
698
+ }}
699
+ .ams-content .ams-lora-file > button .or {{
700
+ font-family: {FONT_MONO} !important;
701
+ font-size:10px !important;
702
+ color:{INK_FAINT} !important;
703
+ letter-spacing:0.04em !important;
704
+ }}
705
+ .ams-content .ams-lora-file > button .icon-wrap svg {{
706
+ color:{INK_MUTED} !important;
707
+ opacity:0.7 !important;
708
+ width:18px !important;
709
+ height:18px !important;
710
+ }}
711
+ /* The floating label that appears once a file is uploaded — give it
712
+ the standard Brutalist mono treatment and hide its decorative SVG
713
+ so the label text reads cleanly. */
714
+ .ams-content .ams-lora-file > label.float {{
715
+ font-family: {FONT_MONO} !important;
716
+ font-size:10px !important;
717
+ letter-spacing:0.08em !important;
718
+ text-transform:uppercase !important;
719
+ color:{INK_MUTED} !important;
720
+ background:transparent !important;
721
+ border:none !important;
722
+ padding:0 0 6px 0 !important;
723
+ }}
724
+ .ams-content .ams-lora-file > label.float svg {{
725
+ display:none !important;
726
+ }}
727
+
728
+ /* Strength slider — the .info text just below it inherits the
729
+ generic helper rule, so no extra work needed. */
730
+ .ams-content .ams-lora-strength input[type="range"] {{
731
+ accent-color:{PRIMARY} !important;
732
+ }}
733
+
734
+ /* Active LoRA display — high-contrast block, mono font, code in
735
+ white so the LoRA name pops. The accordion's own background is
736
+ SURFACE_STRONG (true black), so use a slightly raised surface
737
+ here to make the bordered box visible. */
738
+ .ams-content .ams-lora-active .prose p {{
739
+ font-family: {FONT_MONO} !important;
740
+ font-size:11px !important;
741
+ color:{INK} !important;
742
+ background:{SURFACE_RAISED} !important;
743
+ border:1px solid {BORDER_STRONG} !important;
744
+ border-radius:3px !important;
745
+ padding:8px 10px !important;
746
+ margin:8px 0 0 0 !important;
747
+ line-height:1.5 !important;
748
+ }}
749
+ .ams-content .ams-lora-active .prose code {{
750
+ background:transparent !important;
751
+ color:{PRIMARY} !important;
752
+ font-family: {FONT_MONO} !important;
753
+ font-size:11px !important;
754
+ padding:0 !important;
755
+ }}
756
+ .ams-content .ams-lora-active .prose em {{
757
+ color:{INK_FAINT} !important;
758
+ font-style:italic !important;
759
+ font-family: {FONT_SANS} !important;
760
+ }}
761
+ @media (max-width: 640px) {{
762
+ .ams-content .ams-lora > .label-wrap,
763
+ .ams-content .ams-lora summary,
764
+ .ams-content .ams-lora > button {{
765
+ font-size:9px !important;
766
+ padding:8px 10px !important;
767
+ }}
768
+ .ams-content .ams-lora-note p {{
769
+ font-size:9px !important;
770
+ padding:0 10px !important;
771
+ }}
772
+ .ams-content .ams-lora > div:not(.label-wrap):not(summary) {{
773
+ padding:0 10px 10px 10px !important;
774
+ }}
775
+ .ams-content .ams-lora-active .prose p {{
776
+ font-size:10px !important;
777
+ padding:6px 8px !important;
778
+ }}
779
+ .ams-content .ams-lora .ams-lora-preset.ams-lora-preset > .wrap > label {{
780
+ font-size:10px !important;
781
+ padding:5px 9px !important;
782
+ }}
783
+ .ams-content .ams-lora-file > button {{
784
+ min-height:64px !important;
785
+ padding:10px 8px !important;
786
+ }}
787
+ }}
788
 
789
  /* Hide Gradio footer + the floating "Use via API" / settings panel */
790
  footer {{ display:none !important; }}
ui.py CHANGED
@@ -18,8 +18,15 @@ import tooltips
18
  def build_generate_tab() -> dict[str, gr.components.Component]:
19
  """Generate tab body: 2-column row (form left, output right).
20
 
21
- LoRA / Advanced / LM-planner / DCW accordions are deferred to
22
- M2-M4 and will be added by extending this builder.
 
 
 
 
 
 
 
23
  """
24
  components: dict[str, gr.components.Component] = {}
25
 
@@ -53,6 +60,58 @@ def build_generate_tab() -> dict[str, gr.components.Component]:
53
  label="Vocal mode",
54
  info=tooltips.GENERATE_VOCAL,
55
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  components["generate_btn"] = gr.Button(
57
  "▶ Generate",
58
  variant="primary",
 
18
  def build_generate_tab() -> dict[str, gr.components.Component]:
19
  """Generate tab body: 2-column row (form left, output right).
20
 
21
+ Includes a single-LoRA picker in a collapsed accordion between the
22
+ duration/vocal-mode row and the Generate button. The Apple-Silicon
23
+ ACE-Step fork's AceStepHandler only supports one active LoRA at a
24
+ time (see ``lora_stack.apply_stack`` for the gory details), so the
25
+ UI surfaces a single slot — a preset radio OR a custom upload — and
26
+ a strength slider, with a Markdown "active LoRA" display.
27
+
28
+ Advanced / LM-planner / DCW accordions are deferred to M2-M4 and
29
+ will be added by extending this builder.
30
  """
31
  components: dict[str, gr.components.Component] = {}
32
 
 
60
  label="Vocal mode",
61
  info=tooltips.GENERATE_VOCAL,
62
  )
63
+
64
+ # --- LoRA accordion (collapsed by default) ---
65
+ # Single-LoRA-slot UI: the apple-silicon fork's AceStepHandler
66
+ # can only hold one active adapter, so multi-row stacks are
67
+ # deferred until upstream lands multi-adapter support.
68
+ with gr.Accordion(
69
+ label="LoRA",
70
+ open=False,
71
+ elem_classes=["ams-lora", "ams-lora-accordion"],
72
+ ):
73
+ gr.Markdown(
74
+ "_Only one LoRA at a time on this build. "
75
+ "Picking a preset or uploading a custom file "
76
+ "replaces the active LoRA._",
77
+ elem_classes=["ams-lora-note"],
78
+ )
79
+ components["lora_preset"] = gr.Radio(
80
+ choices=[
81
+ "None",
82
+ "RapMachine",
83
+ "Chinese Rap",
84
+ "Lyric2Vocal",
85
+ "Text2Samples",
86
+ ],
87
+ value="None",
88
+ label="Preset",
89
+ elem_classes=["ams-lora-preset"],
90
+ interactive=True,
91
+ )
92
+ components["lora_upload"] = gr.File(
93
+ label="Custom LoRA (.safetensors)",
94
+ file_types=[".safetensors"],
95
+ file_count="single",
96
+ elem_classes=["ams-lora-file"],
97
+ )
98
+ components["lora_strength"] = gr.Slider(
99
+ minimum=0.0,
100
+ maximum=1.5,
101
+ step=0.05,
102
+ value=0.95,
103
+ label="Strength",
104
+ elem_classes=["ams-lora-strength"],
105
+ )
106
+ components["lora_active"] = gr.Markdown(
107
+ "_No LoRA active_",
108
+ elem_classes=["ams-lora-active"],
109
+ )
110
+ # Hidden state holding the resolved active LoRA dict
111
+ # ``{name, scale, path, sha256}`` so on_generate_click
112
+ # can pass it straight to backend.dispatch.
113
+ components["lora_state"] = gr.State(None)
114
+
115
  components["generate_btn"] = gr.Button(
116
  "▶ Generate",
117
  variant="primary",