feat(audio): complete audio sidebar, audio vault gallery, and domain navigation

This commit is contained in:
DemonGatanjieu
2026-09-20 12:35:13 +08:00
parent 531a25d578
commit 28a24da0c1
12 changed files with 1139 additions and 160 deletions
+5 -3
View File
@@ -84,7 +84,7 @@ DOM or live LiteGraph state.
`api/folder_types.py` own the formerly mixed utility route families.
- `model_policies.py` owns shared backend rename and protected-category policy.
- `model_identity.py` owns file SHA-256 evidence shared with the standalone scanner.
- `api/audio_catalog.py` owns audio preset scanning, voice character grouping, emotion slice indexing, and streaming.
- `api/audio_catalog.py` owns audio preset scanning, voice character grouping, emotion slice indexing, audio streaming, and audio gallery output history listing and deletion.
### Frontend
@@ -93,8 +93,10 @@ DOM or live LiteGraph state.
browser instance plus floating/topbar/menu entry behavior; and
`interface_settings.js` owns language and theme preferences.
- `ui_domain_switcher.js` owns the dual-domain mode toggle (Visual Studio 🎨 vs Audio & Voice Studio 🎙️) embedded in the brand header.
- `ui_audio_studio.js` owns the Audio & Voice Studio workspace, presenting character voice cards, emotion slice playback, prompt syntax copying, and canvas drag-and-drop.
- `ui_sidebar.js` creates the browser shell and folder navigation.
- `ui_audio_studio.js` owns the Audio & Voice Studio workspace, presenting character voice cards, emotion slice playback, prompt syntax copying, real-time search, animated equalizers, and canvas drag-and-drop.
- `ui_audio_sidebar.js` owns the Audio Sidebar navigation, rendering trees for All Voices, Character Presets (e.g. Arona) with count badges, and Output History Vault.
- `ui_audio_gallery.js` owns the Generated Audio Vault, supporting track playback, scrubber progress, duration timecodes, audio download, deletion, and canvas drag-and-drop.
- `ui_sidebar.js` creates the browser shell, domain-aware header tabs (`[音色预设]`, `[音频库]`, `[工作流]`), domain-aware bottom-left tool suppression, and folder navigation.
`ui_settings_hub.js` owns settings and model-card preferences;
`ui_toolbox.js` owns the tool catalog, fixed shortcut bar, and tool dispatch;
`ui_browser_navigation.js` owns shared panel hiding/cleanup and workspace return.
+2
View File
@@ -88,3 +88,5 @@ def setup_routes(app):
# Audio & Voice Studio Routes
app.router.add_get('/anomalous/audio_voices', audio_catalog.api_get_audio_voices)
app.router.add_get('/anomalous/audio_stream', audio_catalog.api_serve_audio)
app.router.add_get('/anomalous/audio_gallery', audio_catalog.api_get_audio_gallery)
app.router.add_post('/anomalous/delete_audio_gallery', audio_catalog.api_delete_audio_gallery)
+87 -4
View File
@@ -118,14 +118,15 @@ async def api_get_audio_voices(request):
async def api_serve_audio(request):
"""GET /anomalous/audio_stream?path=... - Stream audio file securely."""
"""GET /anomalous/audio_stream?path=...&type=input|output - Stream audio file securely."""
rel_path = request.query.get("path", "").strip("/\\")
dir_type = request.query.get("type", "input")
if not rel_path:
return web.Response(status=400, text="Missing audio path")
input_dir = folder_paths.get_input_directory()
base_dir = folder_paths.get_output_directory() if dir_type == "output" else folder_paths.get_input_directory()
try:
resolved_path = resolve_within(input_dir, rel_path)
resolved_path = resolve_within(base_dir, rel_path)
except (ValueError, Exception):
return web.Response(status=403, text="Forbidden path")
@@ -141,8 +142,90 @@ async def api_serve_audio(request):
'.m4a': 'audio/mp4'
}
content_type = content_types.get(ext, 'application/octet-stream')
return web.FileResponse(
resolved_path,
headers={"Content-Type": content_type, "Accept-Ranges": "bytes"}
)
def _collect_gallery_audios(output_dir):
"""Scan output folder for generated audio files."""
audios = []
if not os.path.isdir(output_dir):
return audios
for root, _, files in os.walk(output_dir):
for name in files:
ext = os.path.splitext(name)[1].lower()
if ext not in AUDIO_EXTENSIONS:
continue
full_path = os.path.join(root, name)
try:
stat = os.stat(full_path)
mtime = stat.st_mtime
size = stat.st_size
except OSError:
continue
subfolder = os.path.relpath(root, output_dir)
clean_sub = "" if subfolder == "." else subfolder.replace(os.sep, '/')
rel_url_path = os.path.join(clean_sub, name).replace("\\", "/")
audios.append({
"filename": name,
"subfolder": clean_sub,
"size_bytes": size,
"mtime": mtime,
"audio_url": f"/anomalous/audio_stream?path={rel_url_path}&type=output"
})
audios.sort(key=lambda a: a["mtime"], reverse=True)
return audios
async def api_get_audio_gallery(request):
"""GET /anomalous/audio_gallery?page=1&limit=50 - List generated audio history."""
output_dir = folder_paths.get_output_directory()
page = max(1, int(request.query.get("page", 1)))
limit = max(1, min(100, int(request.query.get("limit", 40))))
all_audios = _collect_gallery_audios(output_dir)
total = len(all_audios)
start = (page - 1) * limit
sliced = all_audios[start:start + limit]
return web.json_response({
"success": True,
"audios": sliced,
"total": total,
"page": page,
"has_more": start + limit < total
})
async def api_delete_audio_gallery(request):
"""POST /anomalous/delete_audio_gallery - Delete a generated audio file."""
try:
data = await request.json()
except Exception:
return web.Response(status=400, text="Invalid JSON payload")
filename = data.get("filename", "").strip()
subfolder = data.get("subfolder", "").strip()
if not filename:
return web.Response(status=400, text="Missing filename")
output_dir = folder_paths.get_output_directory()
try:
rel = os.path.join(subfolder, filename).replace("\\", "/") if subfolder else filename
target = resolve_within(output_dir, rel)
except Exception:
return web.Response(status=403, text="Forbidden path")
if os.path.isfile(target):
try:
os.remove(target)
return web.json_response({"success": True})
except OSError as e:
return web.Response(status=500, text=f"Failed to delete: {e}")
return web.Response(status=404, text="File not found")
+30 -4
View File
@@ -21,7 +21,8 @@ import { showImageWorkbench } from './ui_gallery_detail.js';
import { initDoctorPanel, diagnoseNode, renderGlobalDashboard, openLoraInsertionPicker, runGlobalDoctorScan } from './ui_doctor.js';
import { initAssistantPanel, renderAssistantModelCard, _loadAssistantHistory } from './ui_node_assistant.js';
import { _openGalleryReplacer } from './ui_node_model_picker.js';
import { renderAudioStudio } from './ui_audio_studio.js';
import { renderAudioStudio, stopAudioStudioPlayback } from './ui_audio_studio.js';
import { renderAudioGallery, stopGalleryAudio } from './ui_audio_gallery.js';
import { getActiveDomain } from './ui_domain_switcher.js';
export class AnomalousBrowser {
@@ -50,6 +51,7 @@ export class AnomalousBrowser {
}
this.setTriggerVisible(false);
this.modal.classList.add('visible');
this.updateHeaderTabs?.(getActiveDomain());
if (getActiveDomain() === 'audio') {
this.handleDomainChange('audio');
return;
@@ -62,17 +64,22 @@ export class AnomalousBrowser {
}
handleDomainChange(domain) {
this.updateHeaderTabs?.(domain);
if (domain === 'audio') {
this.hideAllPanels();
if (this.sidebarActions) this.sidebarActions.style.display = 'none';
if (this.modelsBtn) this.setActiveHeaderTab?.(this.modelsBtn);
if (this.audioStudioPanel) {
this.audioStudioPanel.style.display = 'block';
renderAudioStudio(this.audioStudioPanel);
}
this.renderSidebar();
} else {
if (this.audioStudioPanel) {
this.audioStudioPanel.style.display = 'none';
}
if (this.sidebarActions) this.sidebarActions.style.display = 'flex';
if (this.modelsBtn) this.setActiveHeaderTab?.(this.modelsBtn);
this.hideAllPanels();
this.grid.style.display = 'grid';
this.renderSidebar();
if (!this.foldersData) {
this.loadFolders();
} else {
@@ -81,10 +88,29 @@ export class AnomalousBrowser {
}
}
switchAudioTab(tabName, filter = null) {
this.hideAllPanels();
if (tabName === 'gallery') {
if (this.galleryBtn) this.setActiveHeaderTab?.(this.galleryBtn);
if (this.audioGalleryPanel) {
this.audioGalleryPanel.style.display = 'block';
renderAudioGallery(this.audioGalleryPanel);
}
} else {
if (this.modelsBtn) this.setActiveHeaderTab?.(this.modelsBtn);
if (this.audioStudioPanel) {
this.audioStudioPanel.style.display = 'block';
renderAudioStudio(this.audioStudioPanel, filter);
}
}
}
close() {
closeUpdateGuide(this);
this.modal.classList.remove('visible');
this.setTriggerVisible(true);
if (typeof stopAudioStudioPlayback === 'function') stopAudioStudioPlayback();
if (typeof stopGalleryAudio === 'function') stopGalleryAudio();
const canvas = document.getElementById('graph-canvas');
if (canvas instanceof HTMLElement) canvas.focus({ preventScroll: true });
if (this._modelLoadController) this._modelLoadController.abort();
+40 -2
View File
@@ -1255,7 +1255,26 @@ export const i18n = {
audioCopied: "已复制语法",
audioDragHint: "按住卡片可直接拖拽至画布的 F5TTS 节点",
audioEmptyTitle: "暂无音频音色切片",
audioEmptyDesc: "将 5~10 秒的参考音频 (wav/mp3) 和同名文本 (txt) 放入 input/F5-TTS 目录,即可在此管理与试听。"
audioEmptyDesc: "将 5~10 秒的参考音频 (wav/mp3) 和同名文本 (txt) 放入 input/F5-TTS 目录,即可在此管理与试听。",
audioSidebarTitle: "音色分类",
audioRefresh: "刷新音色",
audioAllVoices: "全部音色",
audioSectionCharacters: "角色预设",
audioSectionHistory: "历史归档",
audioGalleryTab: "音频历史库",
audioGalleryTitle: "已生成音频归档",
audioGallerySubtitle: "浏览、试听与管理所有生成的配音文件",
audioGalleryEmpty: "暂无生成的音频文件",
audioDeleteConfirm: "确定要永久删除此音频文件吗?",
audioDeleteSuccess: "已删除音频文件",
audioDownload: "下载音频",
audioDelete: "删除",
audioFileSize: "文件大小",
audioDuration: "时长",
audioTime: "生成时间",
audioTabPresets: "音色预设",
audioTabGallery: "音频库",
audioTabWorkflow: "音频工作流"
},
en: {
actionTools: "Tools",
@@ -2513,7 +2532,26 @@ export const i18n = {
audioCopied: "Tag Copied!",
audioDragHint: "Drag and drop directly onto F5TTS node on canvas",
audioEmptyTitle: "No Voice Presets Found",
audioEmptyDesc: "Place 5-10s reference audios (.wav/.mp3) and matching .txt into input/F5-TTS folder to preview here."
audioEmptyDesc: "Place 5-10s reference audios (.wav/.mp3) and matching .txt into input/F5-TTS folder to preview here.",
audioSidebarTitle: "Voice Categories",
audioRefresh: "Refresh Voices",
audioAllVoices: "All Voices",
audioSectionCharacters: "Characters",
audioSectionHistory: "History",
audioGalleryTab: "Audio Gallery",
audioGalleryTitle: "Generated Audio Vault",
audioGallerySubtitle: "Browse, playback, and manage generated voice files",
audioGalleryEmpty: "No generated audio files found",
audioDeleteConfirm: "Permanently delete this audio file?",
audioDeleteSuccess: "Audio file deleted",
audioDownload: "Download",
audioDelete: "Delete",
audioFileSize: "Size",
audioDuration: "Duration",
audioTime: "Created At",
audioTabPresets: "Voice Presets",
audioTabGallery: "Audio Gallery",
audioTabWorkflow: "Audio Workflows"
}
};
+359
View File
@@ -0,0 +1,359 @@
import { t } from './interface_settings.js';
/**
* Audio Gallery Module (Output History & Management)
* Sleek, DAW-inspired player and track management for ComfyUI generated audio.
*/
let activeAudio = null;
let activePlayBtn = null;
let activeProgressFill = null;
let activeTimeLabel = null;
let progressUpdateTimer = null;
const SVG = {
PLAY: `<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>`,
PAUSE: `<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>`,
DOWNLOAD: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
TRASH: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`,
VAULT: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="20" x="2" y="2" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/></svg>`,
AUDIO_WAVE: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 10v4"/><path d="M6 7v10"/><path d="M10 4v16"/><path d="M14 8v8"/><path d="M18 5v14"/><path d="M22 10v4"/></svg>`
};
export function stopGalleryAudio() {
if (progressUpdateTimer) {
clearInterval(progressUpdateTimer);
progressUpdateTimer = null;
}
if (activeAudio) {
activeAudio.pause();
activeAudio = null;
}
if (activePlayBtn) {
activePlayBtn.innerHTML = SVG.PLAY;
activePlayBtn.classList.remove('is-playing');
activePlayBtn = null;
}
if (activeProgressFill) {
activeProgressFill.style.width = '0%';
activeProgressFill = null;
}
if (activeTimeLabel) {
activeTimeLabel.textContent = '--:--';
activeTimeLabel = null;
}
}
function formatDuration(sec) {
if (!sec || isNaN(sec)) return '00:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function formatBytes(bytes) {
if (!bytes || bytes <= 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
function formatDate(timestampSec) {
if (!timestampSec) return '';
const d = new Date(timestampSec * 1000);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function startPlayback(url, playBtn, fillEl, timeEl) {
if (activePlayBtn === playBtn) {
stopGalleryAudio();
return;
}
stopGalleryAudio();
const audio = new Audio(url);
activeAudio = audio;
activePlayBtn = playBtn;
activeProgressFill = fillEl;
activeTimeLabel = timeEl;
playBtn.innerHTML = SVG.PAUSE;
playBtn.classList.add('is-playing');
audio.onloadedmetadata = () => {
if (timeEl) timeEl.textContent = `00:00 / ${formatDuration(audio.duration)}`;
};
progressUpdateTimer = setInterval(() => {
if (!activeAudio || !activeAudio.duration) return;
const pct = (activeAudio.currentTime / activeAudio.duration) * 100;
if (activeProgressFill) activeProgressFill.style.width = `${pct}%`;
if (activeTimeLabel) {
activeTimeLabel.textContent = `${formatDuration(activeAudio.currentTime)} / ${formatDuration(activeAudio.duration)}`;
}
}, 100);
audio.onended = () => stopGalleryAudio();
audio.onerror = () => stopGalleryAudio();
audio.play().catch(() => stopGalleryAudio());
}
async function requestDeleteTrack(item, rowEl, onDeleted) {
const ok = window.confirm(`${t('audioDeleteConfirm')}\n${item.filename}`);
if (!ok) return;
try {
const resp = await fetch('/anomalous/delete_audio_gallery', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: item.filename, subfolder: item.subfolder })
});
const res = await resp.json();
if (res.success) {
if (activeAudio && activeAudio.src.includes(item.filename)) {
stopGalleryAudio();
}
rowEl.style.opacity = '0';
rowEl.style.transform = 'translateX(20px)';
setTimeout(() => {
rowEl.remove();
if (typeof onDeleted === 'function') onDeleted();
}, 200);
} else {
alert('Failed to delete: ' + (res.error || 'Unknown error'));
}
} catch (e) {
alert('Network error while deleting audio');
}
}
function createTrackRow(item, onDeleted) {
const row = document.createElement('div');
row.className = 'anomalous-audio-track-item';
row.draggable = true;
row.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', item.audio_url);
e.dataTransfer.setData('application/json', JSON.stringify(item));
});
// Play/Pause button
const playBtn = document.createElement('button');
playBtn.type = 'button';
playBtn.className = 'anomalous-audio-play-btn';
playBtn.innerHTML = SVG.PLAY;
// Track Meta & Name
const infoCol = document.createElement('div');
infoCol.style.display = 'flex';
infoCol.style.flexDirection = 'column';
infoCol.style.gap = '2px';
infoCol.style.minWidth = '220px';
infoCol.style.maxWidth = '320px';
const nameEl = document.createElement('span');
nameEl.style.fontSize = '13px';
nameEl.style.fontWeight = '500';
nameEl.style.color = '#f1f5f9';
nameEl.style.whiteSpace = 'nowrap';
nameEl.style.overflow = 'hidden';
nameEl.style.textOverflow = 'ellipsis';
nameEl.textContent = item.filename;
nameEl.title = item.filename;
const subMeta = document.createElement('div');
subMeta.style.display = 'flex';
subMeta.style.alignItems = 'center';
subMeta.style.gap = '8px';
subMeta.style.fontSize = '11px';
subMeta.style.color = '#64748b';
if (item.subfolder) {
const folderTag = document.createElement('span');
folderTag.style.background = 'rgba(99, 102, 241, 0.12)';
folderTag.style.color = '#818cf8';
folderTag.style.padding = '1px 5px';
folderTag.style.borderRadius = '3px';
folderTag.textContent = item.subfolder;
subMeta.appendChild(folderTag);
}
const sizeSpan = document.createElement('span');
sizeSpan.textContent = formatBytes(item.size_bytes);
const dateSpan = document.createElement('span');
dateSpan.textContent = formatDate(item.mtime);
subMeta.appendChild(sizeSpan);
subMeta.appendChild(dateSpan);
infoCol.appendChild(nameEl);
infoCol.appendChild(subMeta);
// Progress bar
const progressTrack = document.createElement('div');
progressTrack.className = 'anomalous-track-progress-bar';
const progressFill = document.createElement('div');
progressFill.className = 'anomalous-track-progress-fill';
progressTrack.appendChild(progressFill);
progressTrack.onclick = (e) => {
if (!activeAudio || activePlayBtn !== playBtn) return;
const rect = progressTrack.getBoundingClientRect();
const clickRatio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
if (activeAudio.duration) {
activeAudio.currentTime = clickRatio * activeAudio.duration;
}
};
// Time Label
const timeLabel = document.createElement('span');
timeLabel.style.fontSize = '11px';
timeLabel.style.color = '#94a3b8';
timeLabel.style.fontFamily = 'monospace';
timeLabel.style.minWidth = '90px';
timeLabel.style.textAlign = 'right';
timeLabel.textContent = '--:--';
playBtn.onclick = (e) => {
e.stopPropagation();
startPlayback(item.audio_url, playBtn, progressFill, timeLabel);
};
// Actions
const actionGroup = document.createElement('div');
actionGroup.style.display = 'flex';
actionGroup.style.alignItems = 'center';
actionGroup.style.gap = '6px';
const dlBtn = document.createElement('a');
dlBtn.href = item.audio_url;
dlBtn.download = item.filename;
dlBtn.className = 'anomalous-audio-play-btn';
dlBtn.innerHTML = SVG.DOWNLOAD;
dlBtn.title = t('audioDownload');
dlBtn.style.textDecoration = 'none';
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'anomalous-audio-play-btn';
delBtn.innerHTML = SVG.TRASH;
delBtn.title = t('audioDelete');
delBtn.onmouseenter = () => { delBtn.style.color = '#f87171'; delBtn.style.borderColor = 'rgba(248, 113, 113, 0.4)'; };
delBtn.onmouseleave = () => { delBtn.style.color = ''; delBtn.style.borderColor = ''; };
delBtn.onclick = (e) => {
e.stopPropagation();
requestDeleteTrack(item, row, onDeleted);
};
actionGroup.appendChild(dlBtn);
actionGroup.appendChild(delBtn);
row.appendChild(playBtn);
row.appendChild(infoCol);
row.appendChild(progressTrack);
row.appendChild(timeLabel);
row.appendChild(actionGroup);
return row;
}
function renderGalleryToolbar(totalCount, onRefresh) {
const bar = document.createElement('div');
bar.className = 'anomalous-audio-toolbar';
const left = document.createElement('div');
left.className = 'anomalous-audio-title-group';
const iconBox = document.createElement('div');
iconBox.className = 'anomalous-audio-icon-box';
iconBox.innerHTML = SVG.AUDIO_WAVE;
const titleText = document.createElement('span');
titleText.className = 'anomalous-audio-title-text';
titleText.textContent = t('audioGalleryTitle');
const subText = document.createElement('span');
subText.className = 'anomalous-audio-sub-text';
subText.textContent = t('audioGallerySubtitle');
left.appendChild(iconBox);
left.appendChild(titleText);
left.appendChild(subText);
const right = document.createElement('div');
right.style.display = 'flex';
right.style.alignItems = 'center';
right.style.gap = '10px';
const countBadge = document.createElement('span');
countBadge.style.fontSize = '11px';
countBadge.style.color = '#94a3b8';
countBadge.style.background = 'rgba(255, 255, 255, 0.05)';
countBadge.style.padding = '3px 8px';
countBadge.style.borderRadius = '12px';
countBadge.textContent = `${totalCount} tracks`;
const refreshBtn = document.createElement('button');
refreshBtn.type = 'button';
refreshBtn.className = 'anomalous-audio-play-btn';
refreshBtn.innerHTML = '🔄';
refreshBtn.title = t('audioRefresh');
refreshBtn.onclick = onRefresh;
right.appendChild(countBadge);
right.appendChild(refreshBtn);
bar.appendChild(left);
bar.appendChild(right);
return bar;
}
export async function renderAudioGallery(container) {
stopGalleryAudio();
container.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.className = 'anomalous-audio-gallery-wrapper';
try {
const resp = await fetch('/anomalous/audio_gallery?page=1&limit=100');
const data = await resp.json();
const audios = data.audios || [];
const toolbar = renderGalleryToolbar(audios.length, () => renderAudioGallery(container));
wrapper.appendChild(toolbar);
if (audios.length === 0) {
const emptyEl = document.createElement('div');
emptyEl.style.padding = '80px 20px';
emptyEl.style.textAlign = 'center';
emptyEl.style.color = '#64748b';
emptyEl.innerHTML = `
<div style="font-size:32px;margin-bottom:12px;opacity:0.6;">🎵</div>
<div style="color:#cbd5e1;font-size:14px;font-weight:600;margin-bottom:6px;">${t('audioGalleryEmpty')}</div>
<div style="font-size:12px;max-width:400px;margin:0 auto;line-height:1.6;">${t('audioStudioSubtitle')}</div>
`;
wrapper.appendChild(emptyEl);
} else {
const trackList = document.createElement('div');
trackList.style.display = 'flex';
trackList.style.flexDirection = 'column';
trackList.style.gap = '8px';
audios.forEach(item => {
const row = createTrackRow(item, () => {
const badge = toolbar.querySelector('span');
if (badge) {
const cur = parseInt(badge.textContent) || 0;
badge.textContent = `${Math.max(0, cur - 1)} tracks`;
}
});
trackList.appendChild(row);
});
wrapper.appendChild(trackList);
}
} catch (e) {
console.error('Failed to load audio gallery:', e);
}
container.appendChild(wrapper);
}
+163
View File
@@ -0,0 +1,163 @@
import { t } from './interface_settings.js';
/**
* Audio Sidebar Navigation Module
* Renders structured category trees for voices, characters, and generated audio history.
*/
let activeFilter = { type: 'all', value: null };
export function getActiveAudioFilter() {
return activeFilter;
}
export function setActiveAudioFilter(filter) {
activeFilter = filter || { type: 'all', value: null };
}
const SIDEBAR_SVG = {
MIC: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>`,
ALL_VOICES: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
USER: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`,
DISC: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>`,
REFRESH: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 21h5v-5"/></svg>`
};
function renderSidebarHeader(owner) {
const topBar = document.createElement('div');
topBar.style.display = 'flex';
topBar.style.justifyContent = 'space-between';
topBar.style.alignItems = 'center';
topBar.style.padding = '12px 14px 10px 14px';
topBar.style.borderBottom = '1px solid rgba(255, 255, 255, 0.05)';
const title = document.createElement('h3');
title.style.color = '#f1f5f9';
title.style.margin = '0';
title.style.fontSize = '12px';
title.style.fontWeight = '600';
title.style.letterSpacing = '0.5px';
title.style.display = 'inline-flex';
title.style.alignItems = 'center';
title.style.gap = '8px';
title.innerHTML = `<span style="color:#818cf8;">${SIDEBAR_SVG.MIC}</span><span>${t('audioSidebarTitle')}</span>`;
const refreshBtn = document.createElement('button');
refreshBtn.type = 'button';
refreshBtn.innerHTML = SIDEBAR_SVG.REFRESH;
refreshBtn.title = t('audioRefresh');
refreshBtn.style.background = 'transparent';
refreshBtn.style.border = 'none';
refreshBtn.style.color = '#94a3b8';
refreshBtn.style.cursor = 'pointer';
refreshBtn.style.padding = '4px';
refreshBtn.style.borderRadius = '4px';
refreshBtn.style.display = 'inline-flex';
refreshBtn.style.alignItems = 'center';
refreshBtn.style.justifyContent = 'center';
refreshBtn.onclick = () => renderAudioSidebar(owner);
topBar.appendChild(title);
topBar.appendChild(refreshBtn);
return topBar;
}
function createNavItem(label, iconSvg, count, isSelected, onClick) {
const item = document.createElement('div');
item.className = `anomalous-audio-nav-item${isSelected ? ' active' : ''}`;
const left = document.createElement('div');
left.style.display = 'flex';
left.style.alignItems = 'center';
left.style.gap = '8px';
left.innerHTML = `<span style="opacity:0.85;display:inline-flex;align-items:center;">${iconSvg}</span><span>${label}</span>`;
item.appendChild(left);
if (count !== undefined && count !== null) {
const badge = document.createElement('span');
badge.style.fontSize = '10px';
badge.style.color = isSelected ? '#818cf8' : '#64748b';
badge.style.background = isSelected ? 'rgba(99, 102, 241, 0.2)' : 'rgba(255, 255, 255, 0.05)';
badge.style.padding = '1px 6px';
badge.style.borderRadius = '8px';
badge.textContent = count;
item.appendChild(badge);
}
item.onclick = onClick;
return item;
}
function createSectionLabel(text) {
const sec = document.createElement('div');
sec.style.padding = '12px 14px 4px 14px';
sec.style.fontSize = '10px';
sec.style.fontWeight = '700';
sec.style.letterSpacing = '1px';
sec.style.textTransform = 'uppercase';
sec.style.color = '#64748b';
sec.textContent = text;
return sec;
}
export async function renderAudioSidebar(owner) {
if (!owner || !owner.sidebar) return;
owner.sidebar.innerHTML = '';
owner.sidebar.appendChild(renderSidebarHeader(owner));
const listContainer = document.createElement('div');
listContainer.style.overflowY = 'auto';
listContainer.style.flex = '1';
listContainer.style.padding = '6px 0';
try {
const resp = await fetch('/anomalous/audio_voices');
const data = await resp.json();
const chars = data.characters || [];
// 1. All Voices Item
const allItem = createNavItem(
t('audioAllVoices'), SIDEBAR_SVG.ALL_VOICES, data.total_slices || 0,
activeFilter.type === 'all',
() => {
activeFilter = { type: 'all', value: null };
renderAudioSidebar(owner);
owner.switchAudioTab?.('presets', activeFilter);
}
);
listContainer.appendChild(allItem);
// 2. Character Categories
listContainer.appendChild(createSectionLabel(t('audioSectionCharacters')));
chars.forEach(c => {
const isSel = activeFilter.type === 'character' && activeFilter.value === c.character;
const cItem = createNavItem(
c.character, SIDEBAR_SVG.USER, c.total_slices, isSel,
() => {
activeFilter = { type: 'character', value: c.character };
renderAudioSidebar(owner);
owner.switchAudioTab?.('presets', activeFilter);
}
);
listContainer.appendChild(cItem);
});
// 3. Output History Entry
listContainer.appendChild(createSectionLabel(t('audioSectionHistory')));
const histItem = createNavItem(
t('audioGalleryTab'), SIDEBAR_SVG.DISC, null,
activeFilter.type === 'gallery',
() => {
activeFilter = { type: 'gallery', value: null };
renderAudioSidebar(owner);
owner.switchAudioTab?.('gallery', activeFilter);
}
);
listContainer.appendChild(histItem);
} catch (e) {
console.error('Failed to render audio sidebar:', e);
}
owner.sidebar.appendChild(listContainer);
}
+167 -141
View File
@@ -1,4 +1,5 @@
import { t } from './interface_settings.js';
import { stopGalleryAudio } from './ui_audio_gallery.js';
/**
* Audio & Voice Studio Workspace
@@ -14,10 +15,11 @@ const SVG = {
PAUSE: `<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>`,
MIC: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>`,
COPY: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`,
CHECK: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`
CHECK: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
SEARCH: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`
};
function stopCurrentAudio() {
export function stopAudioStudioPlayback() {
if (globalAudioPlayer) {
globalAudioPlayer.pause();
globalAudioPlayer.currentTime = 0;
@@ -25,8 +27,7 @@ function stopCurrentAudio() {
}
if (currentPlayingBtn) {
currentPlayingBtn.innerHTML = SVG.PLAY;
currentPlayingBtn.style.color = '#94a3b8';
currentPlayingBtn.style.borderColor = 'rgba(255,255,255,0.12)';
currentPlayingBtn.classList.remove('is-playing');
currentPlayingBtn = null;
}
if (currentPlayingBar) {
@@ -37,10 +38,11 @@ function stopCurrentAudio() {
function playAudio(url, playBtn, eqBars) {
if (currentPlayingBtn === playBtn) {
stopCurrentAudio();
stopAudioStudioPlayback();
return;
}
stopCurrentAudio();
stopAudioStudioPlayback();
if (typeof stopGalleryAudio === 'function') stopGalleryAudio();
const audio = new Audio(url);
globalAudioPlayer = audio;
@@ -48,13 +50,12 @@ function playAudio(url, playBtn, eqBars) {
currentPlayingBar = eqBars;
playBtn.innerHTML = SVG.PAUSE;
playBtn.style.color = '#38bdf8';
playBtn.style.borderColor = 'rgba(56, 189, 248, 0.4)';
playBtn.classList.add('is-playing');
if (eqBars) eqBars.style.display = 'inline-flex';
audio.onended = () => stopCurrentAudio();
audio.onerror = () => stopCurrentAudio();
audio.play().catch(() => stopCurrentAudio());
audio.onended = () => stopAudioStudioPlayback();
audio.onerror = () => stopAudioStudioPlayback();
audio.play().catch(() => stopAudioStudioPlayback());
}
function copySyntax(tag, btn) {
@@ -74,18 +75,18 @@ function copySyntax(tag, btn) {
function getEmotionStyle(emotion) {
const raw = String(emotion || '').toLowerCase();
if (raw.includes('happy') || raw.includes('joy')) {
return { color: '#fbbf24', bg: 'rgba(245, 158, 11, 0.1)', border: 'rgba(245, 158, 11, 0.25)' };
return { color: '#fbbf24', bg: 'rgba(245, 158, 11, 0.12)', border: 'rgba(245, 158, 11, 0.3)' };
}
if (raw.includes('sad') || raw.includes('cry')) {
return { color: '#60a5fa', bg: 'rgba(59, 130, 246, 0.1)', border: 'rgba(59, 130, 246, 0.25)' };
return { color: '#60a5fa', bg: 'rgba(59, 130, 246, 0.12)', border: 'rgba(59, 130, 246, 0.3)' };
}
if (raw.includes('surpris') || raw.includes('shock')) {
return { color: '#f472b6', bg: 'rgba(244, 114, 182, 0.1)', border: 'rgba(244, 114, 182, 0.25)' };
return { color: '#f472b6', bg: 'rgba(244, 114, 182, 0.12)', border: 'rgba(244, 114, 182, 0.3)' };
}
if (raw.includes('normal') || raw.includes('calm')) {
return { color: '#34d399', bg: 'rgba(16, 185, 129, 0.1)', border: 'rgba(16, 185, 129, 0.25)' };
return { color: '#34d399', bg: 'rgba(16, 185, 129, 0.12)', border: 'rgba(16, 185, 129, 0.3)' };
}
return { color: '#a78bfa', bg: 'rgba(167, 139, 250, 0.1)', border: 'rgba(167, 139, 250, 0.25)' };
return { color: '#a78bfa', bg: 'rgba(167, 139, 250, 0.12)', border: 'rgba(167, 139, 250, 0.3)' };
}
function renderEqIndicator() {
@@ -94,17 +95,30 @@ function renderEqIndicator() {
barWrap.style.display = 'none';
barWrap.style.alignItems = 'flex-end';
barWrap.style.gap = '2px';
barWrap.style.height = '12px';
barWrap.style.marginRight = '4px';
barWrap.style.height = '14px';
barWrap.style.marginRight = '2px';
for (let i = 0; i < 3; i++) {
const bar = document.createElement('span');
bar.style.width = '2px';
bar.style.height = `${6 + (i % 2) * 6}px`;
bar.style.background = '#38bdf8';
bar.style.borderRadius = '1px';
barWrap.appendChild(bar);
}
const b1 = document.createElement('span');
b1.className = 'anomalous-eq-bar-1';
b1.style.width = '2px';
b1.style.background = '#38bdf8';
b1.style.borderRadius = '1px';
const b2 = document.createElement('span');
b2.className = 'anomalous-eq-bar-2';
b2.style.width = '2px';
b2.style.background = '#818cf8';
b2.style.borderRadius = '1px';
const b3 = document.createElement('span');
b3.className = 'anomalous-eq-bar-3';
b3.style.width = '2px';
b3.style.background = '#38bdf8';
b3.style.borderRadius = '1px';
barWrap.appendChild(b1);
barWrap.appendChild(b2);
barWrap.appendChild(b3);
return barWrap;
}
@@ -112,24 +126,6 @@ function renderSliceRow(slice) {
const row = document.createElement('div');
row.className = 'anomalous-voice-slice-row';
row.draggable = true;
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '8px';
row.style.padding = '6px 10px';
row.style.borderRadius = '6px';
row.style.background = 'rgba(255, 255, 255, 0.02)';
row.style.border = '1px solid rgba(255, 255, 255, 0.05)';
row.style.transition = 'all 0.18s cubic-bezier(0.16, 1, 0.3, 1)';
row.style.cursor = 'grab';
row.addEventListener('mouseenter', () => {
row.style.background = 'rgba(255, 255, 255, 0.05)';
row.style.borderColor = 'rgba(255, 255, 255, 0.12)';
});
row.addEventListener('mouseleave', () => {
row.style.background = 'rgba(255, 255, 255, 0.02)';
row.style.borderColor = 'rgba(255, 255, 255, 0.05)';
});
row.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', slice.syntax_tag);
@@ -138,31 +134,20 @@ function renderSliceRow(slice) {
const playBtn = document.createElement('button');
playBtn.type = 'button';
playBtn.className = 'anomalous-audio-play-btn';
playBtn.innerHTML = SVG.PLAY;
playBtn.title = t('audioPlay');
playBtn.style.display = 'inline-flex';
playBtn.style.alignItems = 'center';
playBtn.style.justifyContent = 'center';
playBtn.style.width = '24px';
playBtn.style.height = '24px';
playBtn.style.borderRadius = '50%';
playBtn.style.border = '1px solid rgba(255, 255, 255, 0.12)';
playBtn.style.background = 'rgba(255, 255, 255, 0.04)';
playBtn.style.color = '#94a3b8';
playBtn.style.cursor = 'pointer';
playBtn.style.flexShrink = '0';
playBtn.style.transition = 'all 0.15s ease';
const eqBars = renderEqIndicator();
const emoStyle = getEmotionStyle(slice.emotion);
const emoTag = document.createElement('span');
emoTag.textContent = slice.emotion.toUpperCase();
emoTag.style.fontSize = '9px';
emoTag.style.fontWeight = '700';
emoTag.style.letterSpacing = '0.5px';
emoTag.style.padding = '2px 5px';
emoTag.style.borderRadius = '3px';
emoTag.style.padding = '2px 6px';
emoTag.style.borderRadius = '4px';
emoTag.style.color = emoStyle.color;
emoTag.style.background = emoStyle.bg;
emoTag.style.border = `1px solid ${emoStyle.border}`;
@@ -185,10 +170,10 @@ function renderSliceRow(slice) {
copyBtn.style.display = 'inline-flex';
copyBtn.style.alignItems = 'center';
copyBtn.style.gap = '4px';
copyBtn.style.padding = '2px 7px';
copyBtn.style.padding = '2px 8px';
copyBtn.style.borderRadius = '4px';
copyBtn.style.border = '1px solid rgba(255, 255, 255, 0.08)';
copyBtn.style.background = 'rgba(0, 0, 0, 0.25)';
copyBtn.style.background = 'rgba(0, 0, 0, 0.3)';
copyBtn.style.color = '#94a3b8';
copyBtn.style.fontSize = '11px';
copyBtn.style.fontFamily = 'monospace';
@@ -217,15 +202,6 @@ function renderSliceRow(slice) {
function renderCharacterCard(charData) {
const card = document.createElement('div');
card.className = 'anomalous-character-voice-card';
card.style.background = 'rgba(22, 22, 27, 0.75)';
card.style.border = '1px solid rgba(255, 255, 255, 0.08)';
card.style.borderRadius = '10px';
card.style.padding = '14px';
card.style.display = 'flex';
card.style.flexDirection = 'column';
card.style.gap = '10px';
card.style.backdropFilter = 'blur(16px)';
card.style.boxShadow = '0 6px 20px rgba(0,0,0,0.3)';
const header = document.createElement('div');
header.style.display = 'flex';
@@ -235,40 +211,53 @@ function renderCharacterCard(charData) {
const titleGroup = document.createElement('div');
titleGroup.style.display = 'flex';
titleGroup.style.alignItems = 'center';
titleGroup.style.gap = '8px';
titleGroup.style.gap = '10px';
const isArona = charData.character.toLowerCase() === 'arona';
const avatar = document.createElement('div');
avatar.style.width = '26px';
avatar.style.height = '26px';
avatar.style.borderRadius = '6px';
avatar.style.background = charData.character.toLowerCase() === 'arona'
? 'linear-gradient(135deg, rgba(56, 189, 248, 0.3), rgba(99, 102, 241, 0.3))'
avatar.style.width = '32px';
avatar.style.height = '32px';
avatar.style.borderRadius = '8px';
avatar.style.background = isArona
? 'linear-gradient(135deg, rgba(14, 165, 233, 0.35), rgba(99, 102, 241, 0.35))'
: 'rgba(255, 255, 255, 0.06)';
avatar.style.border = '1px solid rgba(255, 255, 255, 0.1)';
avatar.style.border = isArona ? '1px solid rgba(56, 189, 248, 0.4)' : '1px solid rgba(255, 255, 255, 0.1)';
avatar.style.boxShadow = isArona ? '0 0 12px rgba(14, 165, 233, 0.25)' : 'none';
avatar.style.display = 'flex';
avatar.style.alignItems = 'center';
avatar.style.justifyContent = 'center';
avatar.style.color = charData.character.toLowerCase() === 'arona' ? '#38bdf8' : '#94a3b8';
avatar.style.color = isArona ? '#38bdf8' : '#94a3b8';
avatar.innerHTML = SVG.MIC;
const nameBox = document.createElement('div');
nameBox.style.display = 'flex';
nameBox.style.flexDirection = 'column';
const name = document.createElement('span');
name.style.fontSize = '13px';
name.style.fontSize = '14px';
name.style.fontWeight = '600';
name.style.letterSpacing = '0.3px';
name.style.color = '#f1f5f9';
name.style.color = '#f8fafc';
name.textContent = charData.character;
const sub = document.createElement('span');
sub.style.fontSize = '11px';
sub.style.color = '#64748b';
sub.textContent = t('audioDragHint');
nameBox.appendChild(name);
nameBox.appendChild(sub);
const countBadge = document.createElement('span');
countBadge.style.fontSize = '10px';
countBadge.style.padding = '2px 6px';
countBadge.style.borderRadius = '10px';
countBadge.style.fontSize = '11px';
countBadge.style.padding = '3px 8px';
countBadge.style.borderRadius = '12px';
countBadge.style.background = 'rgba(255, 255, 255, 0.05)';
countBadge.style.border = '1px solid rgba(255, 255, 255, 0.06)';
countBadge.style.color = '#64748b';
countBadge.style.border = '1px solid rgba(255, 255, 255, 0.08)';
countBadge.style.color = '#94a3b8';
countBadge.textContent = `${charData.total_slices} ${t('audioVoicePresets')}`;
titleGroup.appendChild(avatar);
titleGroup.appendChild(name);
titleGroup.appendChild(nameBox);
header.appendChild(titleGroup);
header.appendChild(countBadge);
card.appendChild(header);
@@ -276,7 +265,7 @@ function renderCharacterCard(charData) {
const sliceList = document.createElement('div');
sliceList.style.display = 'flex';
sliceList.style.flexDirection = 'column';
sliceList.style.gap = '5px';
sliceList.style.gap = '6px';
charData.slices.forEach(slice => {
sliceList.appendChild(renderSliceRow(slice));
@@ -286,75 +275,112 @@ function renderCharacterCard(charData) {
return card;
}
export async function renderAudioStudio(container) {
stopCurrentAudio();
function renderStudioToolbar(onSearch) {
const toolbar = document.createElement('div');
toolbar.className = 'anomalous-audio-toolbar';
const titleGroup = document.createElement('div');
titleGroup.className = 'anomalous-audio-title-group';
const iconBox = document.createElement('div');
iconBox.className = 'anomalous-audio-icon-box';
iconBox.innerHTML = SVG.MIC;
const textGroup = document.createElement('div');
textGroup.style.display = 'flex';
textGroup.style.flexDirection = 'column';
const title = document.createElement('span');
title.className = 'anomalous-audio-title-text';
title.textContent = t('audioStudioTitle');
const desc = document.createElement('span');
desc.className = 'anomalous-audio-sub-text';
desc.style.marginLeft = '0';
desc.textContent = t('audioStudioSubtitle');
textGroup.appendChild(title);
textGroup.appendChild(desc);
titleGroup.appendChild(iconBox);
titleGroup.appendChild(textGroup);
const searchWrap = document.createElement('div');
searchWrap.style.display = 'flex';
searchWrap.style.alignItems = 'center';
searchWrap.style.gap = '6px';
searchWrap.style.background = 'rgba(0, 0, 0, 0.25)';
searchWrap.style.border = '1px solid rgba(255, 255, 255, 0.1)';
searchWrap.style.borderRadius = '8px';
searchWrap.style.padding = '4px 10px';
const searchIcon = document.createElement('span');
searchIcon.style.color = '#64748b';
searchIcon.innerHTML = SVG.SEARCH;
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = t('audioVoicePresets');
searchInput.style.background = 'transparent';
searchInput.style.border = 'none';
searchInput.style.outline = 'none';
searchInput.style.color = '#f1f5f9';
searchInput.style.fontSize = '12px';
searchInput.style.width = '140px';
searchInput.oninput = (e) => onSearch(e.target.value.toLowerCase());
searchWrap.appendChild(searchIcon);
searchWrap.appendChild(searchInput);
toolbar.appendChild(titleGroup);
toolbar.appendChild(searchWrap);
return toolbar;
}
export async function renderAudioStudio(container, filter = null) {
stopAudioStudioPlayback();
container.innerHTML = '';
const studioWrapper = document.createElement('div');
studioWrapper.className = 'anomalous-audio-studio-wrapper';
studioWrapper.style.padding = '16px';
studioWrapper.style.height = '100%';
studioWrapper.style.overflowY = 'auto';
studioWrapper.style.boxSizing = 'border-box';
// Toolbar Header
const toolbar = document.createElement('div');
toolbar.style.display = 'flex';
toolbar.style.alignItems = 'center';
toolbar.style.justifyContent = 'space-between';
toolbar.style.marginBottom = '14px';
toolbar.style.paddingBottom = '10px';
toolbar.style.borderBottom = '1px solid rgba(255, 255, 255, 0.06)';
const titleGroup = document.createElement('div');
titleGroup.style.display = 'flex';
titleGroup.style.alignItems = 'center';
titleGroup.style.gap = '8px';
const iconBox = document.createElement('span');
iconBox.style.color = '#818cf8';
iconBox.style.display = 'flex';
iconBox.innerHTML = SVG.MIC;
const title = document.createElement('span');
title.style.fontSize = '14px';
title.style.fontWeight = '600';
title.style.color = '#f1f5f9';
title.textContent = t('audioStudioTitle');
const desc = document.createElement('span');
desc.style.fontSize = '11px';
desc.style.color = '#64748b';
desc.textContent = t('audioStudioSubtitle');
titleGroup.appendChild(iconBox);
titleGroup.appendChild(title);
titleGroup.appendChild(desc);
toolbar.appendChild(titleGroup);
studioWrapper.appendChild(toolbar);
try {
const resp = await fetch('/anomalous/audio_voices');
const data = await resp.json();
let characters = data.characters || [];
if (!data.characters || data.characters.length === 0) {
// Apply character filter if selected from sidebar
if (filter && filter.type === 'character' && filter.value) {
characters = characters.filter(c => c.character.toLowerCase() === filter.value.toLowerCase());
}
const grid = document.createElement('div');
grid.style.display = 'grid';
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(380px, 1fr))';
grid.style.gap = '16px';
const toolbar = renderStudioToolbar((searchTerm) => {
const cards = grid.querySelectorAll('.anomalous-character-voice-card');
cards.forEach(card => {
const text = card.textContent.toLowerCase();
card.style.display = text.includes(searchTerm) ? 'flex' : 'none';
});
});
studioWrapper.appendChild(toolbar);
if (characters.length === 0) {
const emptyGuide = document.createElement('div');
emptyGuide.style.padding = '60px 20px';
emptyGuide.style.padding = '70px 20px';
emptyGuide.style.textAlign = 'center';
emptyGuide.style.color = '#64748b';
emptyGuide.innerHTML = `
<div style="font-size:28px;margin-bottom:8px;opacity:0.6;">🎙️</div>
<div style="color:#cbd5e1;font-size:13px;font-weight:600;margin-bottom:4px;">${t('audioEmptyTitle')}</div>
<div style="font-size:11px;max-width:380px;margin:0 auto;line-height:1.5;">${t('audioEmptyDesc')}</div>
<div style="font-size:32px;margin-bottom:12px;opacity:0.6;">🎙️</div>
<div style="color:#cbd5e1;font-size:14px;font-weight:600;margin-bottom:6px;">${t('audioEmptyTitle')}</div>
<div style="font-size:12px;max-width:420px;margin:0 auto;line-height:1.6;">${t('audioEmptyDesc')}</div>
`;
studioWrapper.appendChild(emptyGuide);
} else {
const grid = document.createElement('div');
grid.style.display = 'grid';
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(360px, 1fr))';
grid.style.gap = '14px';
data.characters.forEach(charData => {
characters.forEach(charData => {
grid.appendChild(renderCharacterCard(charData));
});
studioWrapper.appendChild(grid);
+1
View File
@@ -75,6 +75,7 @@ export function hideAllPanels() {
if (this.assistantPanel) this.assistantPanel.style.display = 'none';
if (this.paramPanel) this.paramPanel.style.display = 'none';
if (this.audioStudioPanel) this.audioStudioPanel.style.display = 'none';
if (this.audioGalleryPanel) this.audioGalleryPanel.style.display = 'none';
if (this.currentDetailObserver) {
this.currentDetailObserver.disconnect();
this.currentDetailObserver = null;
+67 -6
View File
@@ -10,7 +10,10 @@ import { showUpdateGuide } from './ui_update_guide.js';
import { setScanButtonState } from './ui_scan_wizard.js';
import { createSettingsHub } from './ui_settings_hub.js';
import { createToolbox } from './ui_toolbox.js';
import { createDomainSwitcher } from './ui_domain_switcher.js';
import { createDomainSwitcher, getActiveDomain } from './ui_domain_switcher.js';
import { renderAudioSidebar } from './ui_audio_sidebar.js';
import { renderAudioStudio } from './ui_audio_studio.js';
import { renderAudioGallery } from './ui_audio_gallery.js';
const t = (key, params) => translate(key, params);
@@ -18,6 +21,8 @@ const SIDEBAR_ICONS = {
MODELS: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="anomalous-btn-icon"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>`,
GALLERY: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="anomalous-btn-icon"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`,
RECIPES: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="anomalous-btn-icon"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v6"/><path d="M9 6h6"/><path d="M7.8 7.8l8.4 8.4"/></svg>`,
AUDIO_STUDIO: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="anomalous-btn-icon"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>`,
AUDIO_VAULT: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="anomalous-btn-icon"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>`,
DOCK: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>`,
HELP: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-1px;margin-right:7px;flex-shrink:0;"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
TOOLBOX: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="display:block;"><path d="M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/><rect width="20" height="14" x="2" y="6" rx="2"/><path d="M2 12h20"/><path d="M10 12v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2"/></svg>`,
@@ -257,6 +262,33 @@ export function createDOM() {
galleryBtn.id = 'anomalous-gallery-btn';
galleryBtn.innerHTML = `${SIDEBAR_ICONS.GALLERY}<span class="anomalous-btn-text">${t('gallery') || '图库'}</span>`;
const nbBtn = document.createElement('button');
nbBtn.id = 'anomalous-notebook-btn';
nbBtn.title = t('recipeTitle');
nbBtn.innerHTML = `${SIDEBAR_ICONS.RECIPES}<span class="anomalous-btn-text">${t('recipeTitle')}</span>`;
this.modelsBtn = modelsBtn;
this.galleryBtn = galleryBtn;
this.nbBtn = nbBtn;
const updateHeaderTabs = (domain) => {
const isAudio = domain === 'audio';
if (isAudio) {
modelsBtn.innerHTML = `${SIDEBAR_ICONS.AUDIO_STUDIO}<span class="anomalous-btn-text">${t('audioTabPresets')}</span>`;
galleryBtn.innerHTML = `${SIDEBAR_ICONS.AUDIO_VAULT}<span class="anomalous-btn-text">${t('audioTabGallery')}</span>`;
const nbText = nbBtn.querySelector('.anomalous-btn-text');
if (nbText) nbText.textContent = t('audioTabWorkflow');
if (this.sidebarActions) this.sidebarActions.style.display = 'none';
} else {
modelsBtn.innerHTML = `${SIDEBAR_ICONS.MODELS}<span class="anomalous-btn-text">${t('models')}</span>`;
galleryBtn.innerHTML = `${SIDEBAR_ICONS.GALLERY}<span class="anomalous-btn-text">${t('gallery') || '图库'}</span>`;
const nbText = nbBtn.querySelector('.anomalous-btn-text');
if (nbText) nbText.textContent = t('recipeTitle');
if (this.sidebarActions) this.sidebarActions.style.display = 'flex';
}
};
this.updateHeaderTabs = updateHeaderTabs;
const setActiveHeaderTab = (btn) => {
modelsBtn.classList.remove('active');
galleryBtn.classList.remove('active');
@@ -268,6 +300,17 @@ export function createDOM() {
modelsBtn.onclick = () => {
this.hideAllPanels();
setActiveHeaderTab(modelsBtn);
if (getActiveDomain() === 'audio') {
showSidebar();
menuBtn.disabled = false;
menuBtn.style.opacity = '1';
menuBtn.style.cursor = 'pointer';
if (this.audioStudioPanel) {
this.audioStudioPanel.style.display = 'block';
renderAudioStudio(this.audioStudioPanel);
}
return;
}
if (localStorage.getItem('anomalous_user_sidebar_closed') === 'true') {
container.classList.add('anomalous-sidebar-closed');
} else {
@@ -287,6 +330,17 @@ export function createDOM() {
galleryBtn.onclick = () => {
this.hideAllPanels();
setActiveHeaderTab(galleryBtn);
if (getActiveDomain() === 'audio') {
showSidebar();
menuBtn.disabled = false;
menuBtn.style.opacity = '1';
menuBtn.style.cursor = 'pointer';
if (this.audioGalleryPanel) {
this.audioGalleryPanel.style.display = 'block';
renderAudioGallery(this.audioGalleryPanel);
}
return;
}
this.gallerySelectModel = null;
this.galleryPanel.classList.remove('is-cover-selecting');
const selectBanner = document.getElementById('anomalous-gallery-select-banner');
@@ -320,11 +374,6 @@ export function createDOM() {
container.classList.add('anomalous-docked');
}
const nbBtn = document.createElement('button');
nbBtn.id = 'anomalous-notebook-btn';
nbBtn.title = t('recipeTitle');
nbBtn.innerHTML = `${SIDEBAR_ICONS.RECIPES}<span class="anomalous-btn-text">${t('recipeTitle')}</span>`;
const dBtn = document.getElementById('anomalous-doctor-btn');
if (dBtn) { dBtn.removeAttribute('title'); dBtn.setAttribute('aria-label', t('sidebarDoctor')); }
const aBtn = document.getElementById('anomalous-assistant-btn');
@@ -503,6 +552,13 @@ export function createDOM() {
this.audioStudioPanel.style.height = '100%';
this.audioStudioPanel.style.overflow = 'hidden';
this.audioGalleryPanel = document.createElement('div');
this.audioGalleryPanel.id = 'anomalous-audio-gallery-panel';
this.audioGalleryPanel.style.display = 'none';
this.audioGalleryPanel.style.flex = '1';
this.audioGalleryPanel.style.height = '100%';
this.audioGalleryPanel.style.overflow = 'hidden';
this.galleryGrid = document.createElement('div');
this.galleryGrid.className = 'anomalous-gallery-grid';
this.galleryPanel.appendChild(this.galleryGrid);
@@ -544,6 +600,7 @@ export function createDOM() {
content.appendChild(this.doctorPanel);
content.appendChild(this.assistantPanel);
content.appendChild(this.audioStudioPanel);
content.appendChild(this.audioGalleryPanel);
container.appendChild(this.sidebarWrapper);
container.appendChild(content);
@@ -588,6 +645,10 @@ export function createDOM() {
export function renderSidebar() {
if (getActiveDomain() === 'audio') {
renderAudioSidebar(this);
return;
}
this.sidebar.innerHTML = '';
const topBar = document.createElement('div');
+1
View File
@@ -9,3 +9,4 @@
@import url("./styles/08-theme-gallery-overrides.css?v=20260918-model-sources-1");
@import url("./styles/09-prompt-drawers.css?v=20260918-model-sources-1");
@import url("./styles/10-model-sources.css?v=20260918-model-sources-1");
@import url("./styles/11-audio-studio.css?v=20260920-audio-1");
+217
View File
@@ -0,0 +1,217 @@
/**
* 11-audio-studio.css
* Dark Glassmorphism Studio / DAW Design for Audio & Voice Ecosystem
*/
/* Equalizer bar keyframes */
@keyframes eq-pulse-1 {
0%, 100% { height: 4px; }
50% { height: 12px; }
}
@keyframes eq-pulse-2 {
0%, 100% { height: 12px; }
50% { height: 5px; }
}
@keyframes eq-pulse-3 {
0%, 100% { height: 7px; }
50% { height: 14px; }
}
.anomalous-eq-bar-1 { animation: eq-pulse-1 0.65s ease-in-out infinite; }
.anomalous-eq-bar-2 { animation: eq-pulse-2 0.5s ease-in-out infinite; }
.anomalous-eq-bar-3 { animation: eq-pulse-3 0.8s ease-in-out infinite; }
/* Workspace Containers */
.anomalous-audio-studio-wrapper,
.anomalous-audio-gallery-wrapper {
height: 100%;
overflow-y: auto;
padding: 20px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 16px;
background: radial-gradient(circle at top right, rgba(99, 102, 241, 0.05), transparent 40%),
radial-gradient(circle at bottom left, rgba(56, 189, 248, 0.04), transparent 40%);
}
/* Header & Toolbars */
.anomalous-audio-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.anomalous-audio-title-group {
display: flex;
align-items: center;
gap: 10px;
}
.anomalous-audio-icon-box {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(56, 189, 248, 0.15));
border: 1px solid rgba(129, 140, 248, 0.3);
color: #818cf8;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.2);
}
.anomalous-audio-title-text {
font-size: 15px;
font-weight: 600;
color: #f8fafc;
letter-spacing: 0.3px;
}
.anomalous-audio-sub-text {
font-size: 12px;
color: #64748b;
margin-left: 6px;
}
/* Character Voice Cards */
.anomalous-character-voice-card {
background: rgba(18, 18, 23, 0.75);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
backdrop-filter: blur(20px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.2s ease, box-shadow 0.2s ease;
}
.anomalous-character-voice-card:hover {
border-color: rgba(99, 102, 241, 0.25);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45), 0 0 15px rgba(99, 102, 241, 0.08);
}
.anomalous-voice-slice-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1);
cursor: grab;
user-select: none;
}
.anomalous-voice-slice-row:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
transform: translateX(2px);
}
.anomalous-voice-slice-row:active {
cursor: grabbing;
}
/* Play Buttons */
.anomalous-audio-play-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: #cbd5e1;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s ease;
}
.anomalous-audio-play-btn:hover {
background: rgba(56, 189, 248, 0.15);
border-color: rgba(56, 189, 248, 0.4);
color: #38bdf8;
transform: scale(1.06);
}
.anomalous-audio-play-btn.is-playing {
background: rgba(56, 189, 248, 0.2);
border-color: #38bdf8;
color: #38bdf8;
box-shadow: 0 0 10px rgba(56, 189, 248, 0.3);
}
/* Audio Gallery Tracks */
.anomalous-audio-track-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: 10px;
background: rgba(22, 22, 28, 0.7);
border: 1px solid rgba(255, 255, 255, 0.06);
backdrop-filter: blur(14px);
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.anomalous-audio-track-item:hover {
background: rgba(28, 28, 36, 0.85);
border-color: rgba(129, 140, 248, 0.3);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
}
.anomalous-track-progress-bar {
flex: 1;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
position: relative;
overflow: hidden;
cursor: pointer;
}
.anomalous-track-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #38bdf8, #818cf8);
border-radius: 2px;
transition: width 0.1s linear;
}
/* Audio Sidebar Nav */
.anomalous-audio-nav-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
margin: 2px 8px;
border-radius: 8px;
cursor: pointer;
font-size: 12px;
color: #cbd5e1;
transition: all 0.15s ease;
border-left: 2px solid transparent;
}
.anomalous-audio-nav-item:hover {
background: rgba(255, 255, 255, 0.04);
color: #f1f5f9;
}
.anomalous-audio-nav-item.active {
background: rgba(99, 102, 241, 0.14);
color: #818cf8;
border-left-color: #818cf8;
font-weight: 600;
}