diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1b5769f..4be92aa 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -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.
diff --git a/api/__init__.py b/api/__init__.py
index 113732b..fd0e758 100644
--- a/api/__init__.py
+++ b/api/__init__.py
@@ -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)
diff --git a/api/audio_catalog.py b/api/audio_catalog.py
index 410ccde..701f315 100644
--- a/api/audio_catalog.py
+++ b/api/audio_catalog.py
@@ -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")
+
diff --git a/web/modules/browser.js b/web/modules/browser.js
index 76081b8..15c6a0d 100644
--- a/web/modules/browser.js
+++ b/web/modules/browser.js
@@ -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();
diff --git a/web/modules/locales.js b/web/modules/locales.js
index 4f3556b..4b185b1 100644
--- a/web/modules/locales.js
+++ b/web/modules/locales.js
@@ -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"
}
};
diff --git a/web/modules/ui_audio_gallery.js b/web/modules/ui_audio_gallery.js
new file mode 100644
index 0000000..733faee
--- /dev/null
+++ b/web/modules/ui_audio_gallery.js
@@ -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: ``,
+ PAUSE: ``,
+ DOWNLOAD: ``,
+ TRASH: ``,
+ VAULT: ``,
+ AUDIO_WAVE: ``
+};
+
+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 = `
+
🎵
+ ${t('audioGalleryEmpty')}
+ ${t('audioStudioSubtitle')}
+ `;
+ 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);
+}
diff --git a/web/modules/ui_audio_sidebar.js b/web/modules/ui_audio_sidebar.js
new file mode 100644
index 0000000..8aeb21a
--- /dev/null
+++ b/web/modules/ui_audio_sidebar.js
@@ -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: ``,
+ ALL_VOICES: ``,
+ USER: ``,
+ DISC: ``,
+ REFRESH: ``
+};
+
+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 = `${SIDEBAR_SVG.MIC}${t('audioSidebarTitle')}`;
+
+ 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 = `${iconSvg}${label}`;
+
+ 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);
+}
diff --git a/web/modules/ui_audio_studio.js b/web/modules/ui_audio_studio.js
index 5fe5ddf..a1e7625 100644
--- a/web/modules/ui_audio_studio.js
+++ b/web/modules/ui_audio_studio.js
@@ -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: ``,
MIC: ``,
COPY: ``,
- CHECK: ``
+ CHECK: ``,
+ SEARCH: ``
};
-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 = `
- 🎙️
- ${t('audioEmptyTitle')}
- ${t('audioEmptyDesc')}
+ 🎙️
+ ${t('audioEmptyTitle')}
+ ${t('audioEmptyDesc')}
`;
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);
diff --git a/web/modules/ui_browser_navigation.js b/web/modules/ui_browser_navigation.js
index 2277de9..cf43acb 100644
--- a/web/modules/ui_browser_navigation.js
+++ b/web/modules/ui_browser_navigation.js
@@ -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;
diff --git a/web/modules/ui_sidebar.js b/web/modules/ui_sidebar.js
index 371274a..232b62f 100644
--- a/web/modules/ui_sidebar.js
+++ b/web/modules/ui_sidebar.js
@@ -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: ``,
GALLERY: ``,
RECIPES: ``,
+ AUDIO_STUDIO: ``,
+ AUDIO_VAULT: ``,
DOCK: ``,
HELP: ``,
TOOLBOX: ``,
@@ -257,6 +262,33 @@ export function createDOM() {
galleryBtn.id = 'anomalous-gallery-btn';
galleryBtn.innerHTML = `${SIDEBAR_ICONS.GALLERY}${t('gallery') || '图库'}`;
+ const nbBtn = document.createElement('button');
+ nbBtn.id = 'anomalous-notebook-btn';
+ nbBtn.title = t('recipeTitle');
+ nbBtn.innerHTML = `${SIDEBAR_ICONS.RECIPES}${t('recipeTitle')}`;
+
+ this.modelsBtn = modelsBtn;
+ this.galleryBtn = galleryBtn;
+ this.nbBtn = nbBtn;
+
+ const updateHeaderTabs = (domain) => {
+ const isAudio = domain === 'audio';
+ if (isAudio) {
+ modelsBtn.innerHTML = `${SIDEBAR_ICONS.AUDIO_STUDIO}${t('audioTabPresets')}`;
+ galleryBtn.innerHTML = `${SIDEBAR_ICONS.AUDIO_VAULT}${t('audioTabGallery')}`;
+ 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}${t('models')}`;
+ galleryBtn.innerHTML = `${SIDEBAR_ICONS.GALLERY}${t('gallery') || '图库'}`;
+ 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}${t('recipeTitle')}`;
-
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');
diff --git a/web/styles.css b/web/styles.css
index 8ad0920..81cdc3a 100644
--- a/web/styles.css
+++ b/web/styles.css
@@ -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");
diff --git a/web/styles/11-audio-studio.css b/web/styles/11-audio-studio.css
new file mode 100644
index 0000000..b4f2ffe
--- /dev/null
+++ b/web/styles/11-audio-studio.css
@@ -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;
+}