Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/lora.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ Use `--session-option` on the CLI, or the same keys in a server model's
they do not add inference graph operations or modify the base files.
Pre-v1 GGUF packages still require a current external or compiled model spec.

In Studio, the **LoRA adapter** group provides a server-side adapter path and
strength for Qwen3-TTS/ASR, VoxCPM1/2, Fish Audio, CosyVoice3, F5-TTS, OuteTTS,
and VibeVoice. **Browse folder** selects an adapter directory on the server;
individual checkpoint paths can also be entered. Keep required configuration
and sidecar files beside the adapter.
Changing or clearing an adapter reloads the model before the next generation;
clearing it restores the base model. A blank strength uses the model's adapter
default. Models configured by the server show these settings read-only; edit
their `session_options` in the server config. ACE-Step and YuE2 use the same
group style; YuE2 retains separate AR/NAR adapters and upload controls.

```bash
audiocpp_cli --family qwen3_asr --task asr --model base.gguf \
--backend cuda --audio speech.wav \
Expand Down
110 changes: 55 additions & 55 deletions webui/native/dist/index.html

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions webui/native/src/lib/LoraControls.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<script lang="ts">
import LoraGroup from './LoraGroup.svelte';
import type { ParamSpec } from './types';

export let specs: ParamSpec[];
export let values: Record<string, unknown>;
export let editable = false;
export let busy = false;
export let onChange: (spec: ParamSpec, value: unknown) => void;
export let onBrowse: (spec: ParamSpec) => void;

$: adapter = specs.find((spec) => spec.name === 'lora');
$: strength = specs.find((spec) => spec.name === 'lora_scale');
$: selected = String(values.lora ?? '').trim();
</script>

{#if adapter && strength}
<LoraGroup selected={selected ? 1 : 0}>
<p>{editable ? 'Applied when the model loads. Reload or generate to apply changes.' : 'Managed by the server configuration. Set the adapter and strength in its session options.'}</p>
<div class="lora-fields">
<div>
<label for="lora-adapter-path">Adapter path <span>Optional</span></label>
<div class="lora-path">
<input id="lora-adapter-path" type="text" value={String(values.lora ?? '')}
placeholder="Server adapter file or directory" disabled={busy || !editable}
on:input={(event) => onChange(adapter, event.currentTarget.value.trim())} />
<button type="button" disabled={busy || !editable} on:click={() => onBrowse(adapter)}>Browse folder</button>
</div>
</div>
<div>
<label for="lora-adapter-strength">Strength</label>
<input id="lora-adapter-strength" type="number" step={strength.step}
min={strength.minimum} max={strength.maximum} value={String(values.lora_scale ?? '')}
placeholder="Adapter default" disabled={busy || !editable || !selected}
on:input={(event) => {
if (event.currentTarget.value === '') onChange(strength, '');
else if (Number.isFinite(event.currentTarget.valueAsNumber)) onChange(strength, event.currentTarget.valueAsNumber);
}} />
</div>
</div>
{#if adapter.info}<small>{adapter.info}</small>{/if}
{#if strength.info}<small>{strength.info}</small>{/if}
<button type="button" disabled={busy || !editable || !selected} on:click={() => {
onChange(adapter, '');
onChange(strength, strength.default ?? '');
}}>Clear adapter</button>
</LoraGroup>
{/if}
23 changes: 23 additions & 0 deletions webui/native/src/lib/LoraGroup.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script lang="ts">
import { Layers } from '@lucide/svelte';
export let selected = 0;
</script>

<details class="studio-options lora-controls">
<summary><Layers size={16} />LoRA adapter <span>{selected > 1 ? `${selected} selected` : selected ? 'Selected' : 'None'}</span></summary>
<div class="lora-group-body"><slot /></div>
</details>

<style>
.lora-controls.studio-options { border-bottom: 1px solid var(--line); }
.lora-group-body { display: grid; gap: 12px; padding-bottom: 16px; }
.lora-group-body :global(.lora-fields) { display: grid; grid-template-columns: minmax(0, 1fr) 120px; gap: 12px; }
.lora-group-body :global(.lora-path) { display: flex; gap: 8px; }
.lora-group-body :global(.lora-path input) { min-width: 0; flex: 1; }
.lora-group-body :global(.lora-path button) { flex-shrink: 0; }
.lora-group-body :global(label) { margin-top: 0; }
.lora-group-body :global(p) { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.lora-group-body :global(small) { color: var(--muted); font-size: 11px; line-height: 1.5; }
.lora-group-body :global(button) { justify-self: start; }
@media (max-width: 600px) { .lora-group-body :global(.lora-fields) { grid-template-columns: 1fr; } }
</style>
15 changes: 15 additions & 0 deletions webui/native/src/lib/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface PackageSpec {
packages?: Array<Omit<PackageEntry, 'family'>>;
options?: {
request?: Array<{ name: string; required?: boolean }>;
session?: Array<{ name: string; description?: string; default?: unknown; min?: number; max?: number }>;
};
ui?: {
builtin_voices?: string[];
Expand Down Expand Up @@ -407,6 +408,20 @@ export const parameterCatalog = Object.fromEntries(
])
) as Record<string, ParamSpec[]>;

for (const [family, spec] of specsByFamily) {
const adapter = spec.options?.session?.find((option) => option.name === 'lora');
const strength = spec.options?.session?.find((option) => option.name === 'lora_scale');
const controls = parameterCatalog[family] || [];
if (!adapter || !strength || controls.some((control) => control.session_option === `${family}.lora`)) continue;
parameterCatalog[family] = [...controls,
{ name: 'lora', group: 'lora', scope: 'session', session_option: `${family}.lora`,
type: 'text', label: 'Adapter path', default: '', info: adapter.description },
{ name: 'lora_scale', group: 'lora', scope: 'session', session_option: `${family}.lora_scale`,
type: 'number', label: 'Strength', default: strength.default ?? '',
minimum: strength.min, maximum: strength.max, step: 0.1, info: strength.description }
];
}

export const taskLabels: Record<string, string> = {
tts: 'Text to speech',
clon: 'Voice cloning',
Expand Down
28 changes: 14 additions & 14 deletions webui/native/src/lib/models/ace_step/AceStepPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { runTask } from '$lib/api';
import type { Translator } from '$lib/i18n';
import type { ParamSpec } from '$lib/types';
import LoraGroup from '$lib/LoraGroup.svelte';

export let modelId: string;
export let text = '';
Expand All @@ -23,6 +24,7 @@
export let requestOptions: () => Record<string, unknown>;
export let resolveRequestSeed: (value: number) => number;
export let setParameterValue: (spec: ParamSpec, value: unknown) => void;
export let onBrowseAdapter: (spec: ParamSpec) => void;
export let log: (message: string) => void;
export let tr: Translator;

Expand Down Expand Up @@ -101,15 +103,18 @@
</div>

{#if loraSpec && scaleSpec}
<details>
<summary>LoRA <span>{advancedValues.lora ? 'Selected' : 'None'}</span></summary>
<LoraGroup selected={advancedValues.lora ? 1 : 0}>
<div class="lora-fields">
<div>
<label for="ace-lora">Adapter path <span>{tr('request.optional')}</span></label>
<input id="ace-lora" type="text" disabled={busy || rewritingCaption || !adapterEditable}
title={adapterEditable ? '' : 'Set adapter session options in the server configuration.'}
value={String(advancedValues.lora ?? '')} placeholder="Server adapter file or PEFT directory"
on:input={(event) => setParameterValue(loraSpec, event.currentTarget.value.trim())} />
<div class="lora-path">
<input id="ace-lora" type="text" disabled={busy || rewritingCaption || !adapterEditable}
title={adapterEditable ? '' : 'Set adapter session options in the server configuration.'}
value={String(advancedValues.lora ?? '')} placeholder="Server adapter file or PEFT directory"
on:input={(event) => setParameterValue(loraSpec, event.currentTarget.value.trim())} />
<button type="button" disabled={busy || rewritingCaption || !adapterEditable}
on:click={() => onBrowseAdapter(loraSpec)}>Browse folder</button>
</div>
</div>
<div>
<label for="ace-lora-scale">LoRA strength</label>
Expand All @@ -119,13 +124,8 @@
if (Number.isFinite(event.currentTarget.valueAsNumber)) setParameterValue(scaleSpec, event.currentTarget.valueAsNumber);
}} />
</div>
<button type="button" disabled={busy || rewritingCaption || !adapterEditable || !advancedValues.lora}
on:click={() => setParameterValue(loraSpec, '')}>Clear adapter</button>
</div>
</details>
<button type="button" disabled={busy || rewritingCaption || !adapterEditable || !advancedValues.lora}
on:click={() => setParameterValue(loraSpec, '')}>Clear adapter</button>
</LoraGroup>
{/if}

<style>
.lora-fields { display: grid; gap: 12px; padding: 12px; }
.lora-fields button { justify-self: start; }
</style>
12 changes: 9 additions & 3 deletions webui/native/src/lib/models/yue2/Yue2Panel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { onDestroy, onMount, tick } from 'svelte';
import { jsonRequest, loadModel, models, runTask, unloadModel, uploadFile } from '$lib/api';
import MediaPreview from '$lib/MediaPreview.svelte';
import LoraGroup from '$lib/LoraGroup.svelte';
import type { Translator } from '$lib/i18n';
import type { CatalogEntry, LoadedModel, ParamSpec, ServerHealth } from '$lib/types';

Expand Down Expand Up @@ -304,8 +305,10 @@
{/each}
</div>

{#if specByName('ar_lora') || specByName('nar_lora')}
<LoraGroup selected={Number(Boolean(advancedValues.ar_lora)) + Number(Boolean(advancedValues.nar_lora))}>
{#if specByName('ar_lora')}
<div class="yue2-grid">
<div class="lora-fields">
<div class="yue2-field">
<label for="param-ar_lora">AR LoRA adapter</label>
<input id="param-ar_lora" type="text" placeholder="Server path (.safetensors)"
Expand Down Expand Up @@ -339,7 +342,7 @@
{/if}

{#if specByName('nar_lora')}
<div class="yue2-grid">
<div class="lora-fields">
<div class="yue2-field">
<label for="param-nar_lora">NAR LoRA adapter</label>
<input id="param-nar_lora" type="text" placeholder="Server path (.safetensors)"
Expand Down Expand Up @@ -368,9 +371,12 @@
setNamedParameter('nar_lora_scale', event.currentTarget.valueAsNumber);
}
}} />
<small>Scales the LoRA deltas only; any full vae2llm/llm2vae projection replacement in the adapter stays at full strength.</small>
</div>
</div>
<small>Scales the LoRA deltas only; any full vae2llm/llm2vae projection replacement in the adapter stays at full strength.</small>
{/if}

</LoraGroup>
{/if}

<div class="yue2-grid yue2-grid-core">
Expand Down
1 change: 1 addition & 0 deletions webui/native/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface CatalogEntry {

export interface ParamSpec {
name: string;
group?: 'lora';
type: 'slider' | 'number' | 'bool' | 'text' | 'choice';
scope?: 'request' | 'session';
session_option?: string;
Expand Down
47 changes: 34 additions & 13 deletions webui/native/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import { catalog, parameterCatalog, taskLabels } from '$lib/catalog';
import { createTranslator, resolveUiLanguage, uiLanguages } from '$lib/i18n';
import MediaPreview from '$lib/MediaPreview.svelte';
import LoraControls from '$lib/LoraControls.svelte';
import VoicePicker from '$lib/VoicePicker.svelte';
import { defaultChunkBudget, splitTtsChunks } from '$lib/text';
import { UI_THEME_STORAGE_KEY, resolvedTheme, resolveUiTheme, uiThemes, type UiTheme } from '$lib/theme';
Expand Down Expand Up @@ -158,6 +159,7 @@
let folderBrowserLoading = false;
let folderBrowserError = '';
let folderBrowser: DirectoryBrowserResponse | null = null;
let folderBrowserTarget: ParamSpec | null = null;
let installPoll: number | null = null;
let selectedPackageIds: Record<string, string> = {};
let packageSizes: Record<string, ModelPackageSize> = {};
Expand Down Expand Up @@ -302,11 +304,12 @@

function setParameterValue(spec: ParamSpec, value: unknown) {
advancedValues = { ...advancedValues, [spec.name]: value };
if (selected?.family === 'ace_step' && spec.name === 'lora' && value === '') {
if ((selected?.family === 'ace_step' || spec.group === 'lora') && spec.name === 'lora' && value === '') {
importedSessionOptions = { ...importedSessionOptions };
delete importedSessionOptions['ace_step.lora'];
delete importedSessionOptions[spec.session_option || `${selected.family}.lora`];
if (spec.group === 'lora') delete importedSessionOptions[`${selected.family}.lora_scale`];
}
if (['auk', 'yue2', 'liveavatar', 'fish_drama3', 'ace_step'].includes(selected?.family || '') && spec.scope === 'session') {
if ((spec.group === 'lora' || ['auk', 'yue2', 'liveavatar', 'fish_drama3', 'ace_step'].includes(selected?.family || '')) && spec.scope === 'session') {
isLoaded = loadedModels.some((model) => model.id === selectedId && model.loaded &&
modelMatchesSelectedPackage(model, selected));
}
Expand Down Expand Up @@ -512,8 +515,9 @@
modelMatchesSelectedPackage(model, selected));
$: modelStudioPanelConfig = modelStudioPanelFor(selected?.family, selected?.task);
$: modelStudioPanel = modelStudioPanelConfig?.component;
$: genericParamSpecs = selected?.family === 'ace_step'
? paramSpecs.filter((spec) => !['lora', 'lora_scale'].includes(spec.name)) : paramSpecs;
$: loraParamSpecs = paramSpecs.filter((spec) => spec.group === 'lora');
$: genericParamSpecs = paramSpecs.filter((spec) => spec.group !== 'lora' &&
!(selected?.family === 'ace_step' && ['lora', 'lora_scale'].includes(spec.name)));
$: replacesGenericControls = (modelStudioPanelConfig?.replacesGenericControls || noGenericControlReplacements) as GenericControlReplacements;
$: usesYue2Request = modelStudioPanelConfig?.requestMode === 'yue2';
$: usesPanelRequest = modelStudioPanelConfig?.requestMode === 'panel';
Expand Down Expand Up @@ -1110,7 +1114,8 @@
}
}

async function openFolderBrowser(path = '') {
async function openFolderBrowser(path = '', target: ParamSpec | null = null) {
folderBrowserTarget = target;
folderBrowserOpen = true;
folderBrowserLoading = true;
folderBrowserError = '';
Expand All @@ -1125,7 +1130,8 @@

function selectBrowsedFolder() {
if (!folderBrowser) return;
modelsFolderInput = folderBrowser.current;
if (folderBrowserTarget) setParameterValue(folderBrowserTarget, folderBrowser.current);
else modelsFolderInput = folderBrowser.current;
folderBrowserOpen = false;
}

Expand All @@ -1139,6 +1145,11 @@
!(['vibevoice', 'moss_ttsd'].includes(selected?.family) && spec.name === 'voice_samples') &&
!(hidesDurationSec && spec.name === 'duration_sec'));
advancedValues = Object.fromEntries(byId.map((spec) => [spec.name, spec.default ?? '']));
const sessionOptions = loadedModels.find((model) => model.id === selectedId)?.session_options || {};
for (const spec of paramSpecs.filter((spec) => spec.group === 'lora')) {
const value = sessionOptions[spec.session_option || spec.name];
if (value !== undefined) advancedValues[spec.name] = value;
}
if (selected?.family === 'ace_step' && isConfiguredEntry(selected)) {
const configured = loadedModels.find((model) => model.id === selectedId)?.session_options;
advancedValues = { ...advancedValues,
Expand Down Expand Up @@ -1437,6 +1448,7 @@
function sessionParameterOptions() {
const options = Object.fromEntries(paramSpecs
.filter((spec) => spec.scope === 'session')
.filter((spec) => spec.group !== 'lora' || String(advancedValues.lora ?? '').trim().length > 0)
.map((spec) => [spec.session_option || spec.name, String(advancedValues[spec.name] ?? spec.default ?? '')])
.filter(([, value]) => value.length > 0));
if (selected?.family === 'auk') {
Expand Down Expand Up @@ -2514,6 +2526,7 @@
if (selectedId) workflowSelections = { ...workflowSelections, [activeWorkflow]: selectedId };
resetParams();
await refresh();
if (paramSpecs.some((spec) => spec.group === 'lora')) resetParams();
if (server?.ui_management) {
try {
let root = await getModelsRoot();
Expand Down Expand Up @@ -2741,6 +2754,7 @@
<AceStepPanel modelId={selected.id} bind:text bind:lyrics bind:language bind:duration
{seed} busy={running || loadingModel} bind:rewritingCaption bind:advancedValues
adapterEditable={server?.ui_management === true && !isConfiguredEntry(selected)}
onBrowseAdapter={(spec) => openFolderBrowser(modelsFolder, spec)}
{paramSpecs} bind:outputText bind:outputJson bind:status bind:warningStatus bind:errorStatus
{ensureLoaded} {requestOptions} {resolveRequestSeed} {setParameterValue} {log} {tr} />
{:else if modelStudioPanel}
Expand Down Expand Up @@ -2985,6 +2999,13 @@
</div>
{/if}

{#if loraParamSpecs.length}
<LoraControls specs={loraParamSpecs} values={advancedValues}
editable={server?.ui_management === true && !isConfiguredEntry(selected)}
busy={running || loadingModel} onChange={setParameterValue}
onBrowse={(spec) => openFolderBrowser(modelsFolder, spec)} />
{/if}

{#if genericParamSpecs.length && !replacesGenericControls.params}
<details class="studio-options">
<summary><SlidersHorizontal size={16} />{tr('options.modelParameters')} <span>{genericParamSpecs.length}</span></summary>
Expand Down Expand Up @@ -3178,22 +3199,22 @@
<div class="folder-browser-backdrop">
<div class="panel folder-browser-dialog" role="dialog" aria-modal="true" aria-labelledby="folder-browser-title">
<header>
<div><span>{tr('folder.eyebrow')}</span><h2 id="folder-browser-title">{tr('folder.title')}</h2></div>
<div><span>{tr('folder.eyebrow')}</span><h2 id="folder-browser-title">{folderBrowserTarget ? 'Select LoRA adapter directory' : tr('folder.title')}</h2></div>
<button aria-label={tr('folder.closeLabel')} title={tr('common.close')} on:click={() => folderBrowserOpen = false}>{tr('common.close')}</button>
</header>
<div class="folder-browser-location">{folderBrowser?.current || tr('folder.loading')}</div>
{#if folderBrowser?.roots.length}
<div class="folder-browser-roots">
{#each folderBrowser.roots as root}
<button on:click={() => openFolderBrowser(root)}>{root}</button>
<button on:click={() => openFolderBrowser(root, folderBrowserTarget)}>{root}</button>
{/each}
</div>
{/if}
<div class="folder-browser-toolbar">
<button disabled={!folderBrowser?.parent || folderBrowserLoading}
on:click={() => openFolderBrowser(folderBrowser?.parent || '')}>{tr('folder.up')}</button>
on:click={() => openFolderBrowser(folderBrowser?.parent || '', folderBrowserTarget)}>{tr('folder.up')}</button>
<button disabled={folderBrowserLoading}
on:click={() => openFolderBrowser(folderBrowser?.current || '')}>{tr('common.refresh')}</button>
on:click={() => openFolderBrowser(folderBrowser?.current || '', folderBrowserTarget)}>{tr('common.refresh')}</button>
</div>
{#if folderBrowserError}
<div class="folder-browser-error">{folderBrowserError}</div>
Expand All @@ -3202,7 +3223,7 @@
{:else if folderBrowser?.directories.length}
<div class="folder-browser-list">
{#each folderBrowser.directories as directory}
<button title={directory.path} on:click={() => openFolderBrowser(directory.path)}>
<button title={directory.path} on:click={() => openFolderBrowser(directory.path, folderBrowserTarget)}>
<span class="folder-icon">&gt;</span><span>{directory.name}</span>
</button>
{/each}
Expand All @@ -3213,7 +3234,7 @@
<footer>
<button on:click={() => folderBrowserOpen = false}>{tr('common.cancel')}</button>
<button class="primary" disabled={!folderBrowser || folderBrowserLoading}
on:click={selectBrowsedFolder}>{tr('folder.select')}</button>
on:click={selectBrowsedFolder}>{folderBrowserTarget ? 'Use adapter folder' : tr('folder.select')}</button>
</footer>
</div>
</div>
Expand Down
Loading