Files
DemonGatanjieu-Anomalous_Mo…/api/audio_catalog.py
T
DemonGatanjieuandClaude Opus 5.5 b319fa1027 feat(audio): show each file's speech in the audio gallery and rescue unsaved previews
The gallery listed file names only, and audio from Preview Audio nodes (the
template used one) lives in ComfyUI's temp folder, so the gallery stayed empty.

- api/audio_metadata.py reads ComfyUI's `prompt` comment from FLAC and
  Ogg/Opus files (read-only) and extracts the TTS node's speech, sample, seed
- gallery rows show the speech (emotion chips, expandable per-segment script),
  voice and seed, with copy-speech; results are cached by mtime/size
- search (?q=) matches speech text and file names
- "unsaved previews" section lists temp audio with "save to gallery", which
  copies into output/audio/ (O_EXCL, never overwrites; the preview is untouched)
- audio_stream serves input/output/temp only; .opus is recognized
- parseTaggedSpeech in audio_script.js reads speech back the way F5-TTS splits it

Also outside the repo: ComfyUI/arona_multivoice_workflow.json now ends in a
Save Audio node (prefix audio/F5-TTS/Arona) instead of Preview Audio.

Verified: 100 Python tests (5 new gallery tests with synthetic FLAC/Opus),
tests/audio_script_rules.mjs, jsdom walkthrough (speech rows, preview save,
search, delete, paging), and a read-only run on the two real temp previews.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 09:24:05 +00:00

630 lines
24 KiB
Python

"""Audio and Voice catalog scanning, voice ingestion, and streaming service.
Voice files follow the ComfyUI-F5-TTS multi-voice layout: `Character.wav` +
`Character.txt` is the main sample, `Character.<voice>.wav` + `.txt` are the
variants addressed as `{<voice>}` in the speech text. An optional
`<stem>.orig.txt` keeps the native-script transcript for display.
"""
import asyncio
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import uuid
from urllib.parse import quote
import folder_paths
from aiohttp import web
from .audio_metadata import generation_info
from .path_utils import resolve_within
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.flac', '.ogg', '.opus', '.m4a'}
AUDIO_CONTENT_TYPES = {
'.wav': 'audio/wav',
'.mp3': 'audio/mpeg',
'.flac': 'audio/flac',
'.ogg': 'audio/ogg',
'.opus': 'audio/ogg',
'.m4a': 'audio/mp4',
}
AUDIO_ROOTS = {
"input": lambda: folder_paths.get_input_directory(),
"output": lambda: folder_paths.get_output_directory(),
"temp": lambda: folder_paths.get_temp_directory(),
}
PREVIEW_LIMIT = 30
SAVED_PREVIEW_SUBFOLDER = "audio"
MAX_UPLOAD_BYTES = 100 * 1024 * 1024
FFMPEG_TIMEOUT_SECONDS = 120
VOICE_SCAN_SUBDIRS = ("F5-TTS", "audio", "")
DEFAULT_VOICE_SUBFOLDER = "F5-TTS"
MAIN_VOICE = "main"
RESERVED_VOICE_NAMES = {"orig"}
WORKFLOW_TEMPLATE_NAME = re.compile(r'^[A-Za-z0-9_-]{1,64}$')
# Revalidate instead of forbidding caches: overwritten voices get a new ?v=mtime URL.
AUDIO_CACHE_HEADERS = {"Cache-Control": "no-cache"}
def _audio_url(rel_path, dir_type="input", version=None):
url = f"/anomalous/audio_stream?path={quote(rel_path, safe='/')}"
if dir_type != "input":
url += f"&type={dir_type}"
if version is not None:
url += f"&v={int(version)}"
return url
def parse_voice_name(filename):
"""Split a voice file name the way ComfyUI-F5-TTS does.
`Arona.wav` is the main voice of Arona; `Arona.happy.wav` is the `{happy}`
variant that F5-TTS loads beside a selected `Arona.wav` sample.
"""
stem, _ = os.path.splitext(filename)
main_stem, sep, voice = stem.partition('.')
return main_stem, (voice if sep and voice else MAIN_VOICE)
def _read_text_file(path):
try:
with open(path, 'r', encoding='utf-8-sig', errors='ignore') as f:
return f.read().strip()
except OSError:
return ""
def _read_associated_text(audio_path):
"""Return (display_text, synthesis_text); .orig.txt holds the native script when present."""
stem, _ = os.path.splitext(audio_path)
synthesis = _read_text_file(stem + '.txt') if os.path.isfile(stem + '.txt') else ""
original = _read_text_file(stem + '.orig.txt') if os.path.isfile(stem + '.orig.txt') else ""
return (original or synthesis), synthesis
def _slice_record(directory, name, relative_prefix):
full_path = os.path.join(directory, name)
stem, ext = os.path.splitext(name)
main_stem, voice = parse_voice_name(name)
display_text, synthesis_text = _read_associated_text(full_path)
stat = os.stat(full_path)
rel_path = "/".join(part for part in (relative_prefix, name) if part)
return {
"id": stem,
"filename": name,
"extension": ext.lower(),
"relative_path": rel_path,
"folder": relative_prefix,
"group": "/".join(part for part in (relative_prefix, main_stem) if part),
"character": main_stem,
"emotion": voice,
"is_main": voice == MAIN_VOICE,
"text": display_text,
"synthesis_text": synthesis_text,
# ComfyUI-F5-TTS only lists and loads samples that have a .txt transcript.
"has_transcript": os.path.isfile(os.path.join(directory, stem + '.txt')),
"size_bytes": stat.st_size,
"syntax_tag": f"{{{voice}}}",
"audio_url": _audio_url(rel_path, "input", stat.st_mtime),
}
def _scan_single_audio_dir(directory, relative_prefix=""):
"""Scan one directory for audio files, preferring .wav when several share a stem."""
if not os.path.isdir(directory):
return []
try:
entries = sorted(os.listdir(directory))
except OSError:
return []
chosen = {}
for name in entries:
stem, ext = os.path.splitext(name)
ext = ext.lower()
if ext not in AUDIO_EXTENSIONS or not os.path.isfile(os.path.join(directory, name)):
continue
if stem in chosen and chosen[stem][1] == '.wav':
continue
chosen[stem] = (name, ext)
slices = []
for name, _ in chosen.values():
try:
slices.append(_slice_record(directory, name, relative_prefix))
except OSError:
continue
return slices
def _group_voice_slices(all_slices):
"""One group per F5-TTS main sample: `<folder>/<Character>` plus its `.variant` files."""
groups = {}
for s in all_slices:
group = groups.setdefault(s["group"], {
"group": s["group"],
"character": s["character"],
"folder": s["folder"],
"main_relative_path": None,
"slices": [],
"total_slices": 0,
})
group["slices"].append(s)
group["total_slices"] += 1
if s["is_main"]:
group["main_relative_path"] = s["relative_path"]
for group in groups.values():
main = next((s for s in group["slices"] if s["is_main"]), None)
group["has_main"] = main is not None
group["slices"].sort(key=lambda s: (not s["is_main"], s["emotion"]))
for s in group["slices"]:
# F5-TTS builds variant paths with the main sample's extension.
s["tag_usable"] = bool(main and s["has_transcript"] and main["has_transcript"]
and (s["is_main"] or s["extension"] == main["extension"]))
return list(groups.values())
def _collect_voice_slices(input_dir):
"""Scan the folders ComfyUI-F5-TTS lists samples from."""
all_slices = []
for sub in VOICE_SCAN_SUBDIRS:
all_slices.extend(_scan_single_audio_dir(os.path.join(input_dir, sub) if sub else input_dir, sub))
return all_slices
async def api_get_audio_voices(request):
"""GET /anomalous/audio_voices - Return voice groups (main sample plus variants)."""
all_slices = await asyncio.to_thread(_collect_voice_slices, folder_paths.get_input_directory())
return web.json_response({
"success": True,
"characters": _group_voice_slices(all_slices),
"total_slices": len(all_slices),
})
async def api_serve_audio(request):
"""GET /anomalous/audio_stream?path=...&type=input|output|temp - Stream an audio file."""
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")
if dir_type not in AUDIO_ROOTS:
return web.Response(status=400, text="Unknown audio folder")
base_dir = AUDIO_ROOTS[dir_type]()
try:
resolved_path = resolve_within(base_dir, rel_path)
except ValueError:
return web.Response(status=403, text="Forbidden path")
ext = os.path.splitext(resolved_path)[1].lower()
if ext not in AUDIO_EXTENSIONS:
return web.Response(status=415, text="Not an audio file")
if not os.path.isfile(resolved_path):
return web.Response(status=404, text="Audio file not found")
return web.FileResponse(resolved_path, headers={"Content-Type": AUDIO_CONTENT_TYPES[ext], **AUDIO_CACHE_HEADERS})
def _collect_audio_files(root_dir, dir_type):
"""Scan a folder tree for audio files, newest first."""
audios = []
if not os.path.isdir(root_dir):
return audios
for root, _, files in os.walk(root_dir):
for name in files:
if os.path.splitext(name)[1].lower() not in AUDIO_EXTENSIONS:
continue
try:
stat = os.stat(os.path.join(root, name))
except OSError:
continue
subfolder = os.path.relpath(root, root_dir)
clean_sub = "" if subfolder == "." else subfolder.replace(os.sep, '/')
rel_path = "/".join(part for part in (clean_sub, name) if part)
audios.append({
"filename": name,
"subfolder": clean_sub,
"size_bytes": stat.st_size,
"mtime": stat.st_mtime,
"audio_url": _audio_url(rel_path, dir_type, stat.st_mtime),
})
audios.sort(key=lambda a: a["mtime"], reverse=True)
return audios
_info_cache = {}
def _cached_generation_info(path, mtime, size):
"""Generation info keyed by (mtime, size) so unchanged files are parsed once."""
cached = _info_cache.get(path)
if cached and cached[0] == (mtime, size):
return cached[1]
info = generation_info(path)
if len(_info_cache) > 5000:
_info_cache.clear()
_info_cache[path] = ((mtime, size), info)
return info
def _attach_generation_info(root_dir, items):
for item in items:
path = os.path.join(root_dir, item["subfolder"], item["filename"])
item["generation"] = _cached_generation_info(path, item["mtime"], item["size_bytes"])
return items
def _search_text(item):
speech = (item.get("generation") or {}).get("speech") or ""
return f"{item['subfolder']}/{item['filename']}\n{speech}".lower()
def _gallery_page(output_dir, page, limit, query):
all_audios = _collect_audio_files(output_dir, "output")
if query:
# Searching reads every file's comments once; the cache keeps later searches cheap.
_attach_generation_info(output_dir, all_audios)
all_audios = [item for item in all_audios if query in _search_text(item)]
total = len(all_audios)
start = (page - 1) * limit
items = all_audios[start:start + limit]
if not query:
_attach_generation_info(output_dir, items)
return items, total, start + limit < total
def _positive_int(value, default, maximum=None):
try:
number = int(value)
except (TypeError, ValueError):
return default
number = max(1, number)
return min(maximum, number) if maximum else number
async def api_get_audio_gallery(request):
"""GET /anomalous/audio_gallery?page=1&limit=50&q=text - Generated audio in output, with the speech that produced it."""
page = _positive_int(request.query.get("page"), 1)
limit = _positive_int(request.query.get("limit"), 40, 100)
query = request.query.get("q", "").strip().lower()
items, total, has_more = await asyncio.to_thread(_gallery_page, folder_paths.get_output_directory(), page, limit, query)
return web.json_response({
"success": True,
"audios": items,
"total": total,
"page": page,
"has_more": has_more,
})
def _list_previews(temp_dir):
items = _collect_audio_files(temp_dir, "temp")[:PREVIEW_LIMIT]
return _attach_generation_info(temp_dir, items)
async def api_get_audio_previews(request):
"""GET /anomalous/audio_previews - Unsaved preview audio in ComfyUI's temp folder (cleared on restart)."""
items = await asyncio.to_thread(_list_previews, folder_paths.get_temp_directory())
return web.json_response({"success": True, "audios": items})
def _save_preview(temp_dir, output_dir, subfolder, filename):
source = resolve_within(temp_dir, subfolder, filename)
ext = os.path.splitext(filename)[1].lower()
if ext not in AUDIO_EXTENSIONS:
raise ValueError("Only audio files can be saved")
if not os.path.isfile(source):
raise FileNotFoundError(filename)
dest_dir = resolve_within(output_dir, SAVED_PREVIEW_SUBFOLDER)
os.makedirs(dest_dir, exist_ok=True)
stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(os.path.getmtime(source)))
for counter in range(1, 1000):
name = f"Preview_{stamp}_{counter:02}{ext}"
target = os.path.join(dest_dir, name)
try:
# O_EXCL: never overwrite an existing file in the library.
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0))
except FileExistsError:
continue
try:
with os.fdopen(fd, "wb") as out, open(source, "rb") as src:
shutil.copyfileobj(src, out)
except Exception:
try:
os.remove(target)
except OSError:
pass
raise
stat = os.stat(target)
rel_path = f"{SAVED_PREVIEW_SUBFOLDER}/{name}"
item = {
"filename": name,
"subfolder": SAVED_PREVIEW_SUBFOLDER,
"size_bytes": stat.st_size,
"mtime": stat.st_mtime,
"audio_url": _audio_url(rel_path, "output", stat.st_mtime),
}
return _attach_generation_info(output_dir, [item])[0]
raise FileExistsError("No free file name")
async def api_save_audio_preview(request):
"""POST /anomalous/save_audio_preview {filename, subfolder} - Copy a temp preview into output/audio."""
try:
data = await request.json()
except Exception:
return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400)
filename = str(data.get("filename", "")).strip()
subfolder = str(data.get("subfolder", "")).strip().strip("/\\")
if not filename or os.path.basename(filename) != filename:
return web.json_response({"success": False, "error": "Invalid filename"}, status=400)
try:
item = await asyncio.to_thread(
_save_preview, folder_paths.get_temp_directory(), folder_paths.get_output_directory(), subfolder, filename)
except ValueError as e:
return web.json_response({"success": False, "error": str(e)}, status=403)
except FileNotFoundError:
return web.json_response({"success": False, "code": "missing", "error": "Preview no longer exists"}, status=404)
except OSError as e:
return web.json_response({"success": False, "error": f"Failed to save: {e}"}, status=500)
return web.json_response({"success": True, "audio": item})
async def api_delete_audio_gallery(request):
"""POST /anomalous/delete_audio_gallery - Delete one generated audio file."""
try:
data = await request.json()
except Exception:
return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400)
filename = str(data.get("filename", "")).strip()
subfolder = str(data.get("subfolder", "")).strip().strip("/\\")
if not filename or os.path.basename(filename) != filename:
return web.json_response({"success": False, "error": "Invalid filename"}, status=400)
if os.path.splitext(filename)[1].lower() not in AUDIO_EXTENSIONS:
return web.json_response({"success": False, "error": "Only audio files can be deleted here"}, status=415)
try:
target = resolve_within(folder_paths.get_output_directory(), subfolder, filename)
except ValueError:
return web.json_response({"success": False, "error": "Forbidden path"}, status=403)
if not os.path.isfile(target):
return web.json_response({"success": False, "error": "File not found"}, status=404)
try:
await asyncio.to_thread(os.remove, target)
except OSError as e:
return web.json_response({"success": False, "error": f"Failed to delete: {e}"}, status=500)
return web.json_response({"success": True})
def _read_workflow_template(name):
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
filename = f"{name}_multivoice_workflow.json"
for wf_path in (os.path.join(folder_paths.base_path, filename), os.path.join(plugin_dir, "workflows", filename)):
if os.path.isfile(wf_path):
with open(wf_path, 'r', encoding='utf-8') as f:
return json.load(f)
return None
async def api_get_audio_template_workflow(request):
"""GET /anomalous/audio_template_workflow?name=arona - Return a local multi-voice workflow template."""
name = request.query.get("name", "arona").strip().lower()
if not WORKFLOW_TEMPLATE_NAME.match(name):
return web.json_response({"success": False, "error": "Invalid template name"}, status=400)
try:
workflow = await asyncio.to_thread(_read_workflow_template, name)
except (OSError, ValueError) as e:
return web.json_response({"success": False, "error": f"Failed to read workflow: {e}"}, status=500)
if workflow is None:
return web.json_response({
"success": False,
"code": "template_missing",
"error": f"{name}_multivoice_workflow.json not found",
}, status=404)
return web.json_response({"success": True, "workflow": workflow})
def _ffmpeg_executable():
"""Prefer ffmpeg on PATH, then the copies shipped beside the portable Python."""
found = shutil.which("ffmpeg")
if found:
return found
python_dir = os.path.dirname(sys.executable)
for candidate in (os.path.join(python_dir, "ffmpeg.exe"), os.path.join(python_dir, "Scripts", "ffmpeg.exe")):
if os.path.isfile(candidate):
return candidate
return None
def _convert_to_pcm_wav(src_path, dst_path):
"""Convert audio to 16-bit 24kHz mono PCM WAV. Returns an error message or None."""
ffmpeg = _ffmpeg_executable()
if not ffmpeg:
return "ffmpeg not found"
cmd = [ffmpeg, "-y", "-i", src_path, "-ar", "24000", "-ac", "1", "-c:a", "pcm_s16le", dst_path]
try:
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, timeout=FFMPEG_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
return "ffmpeg timed out"
except (OSError, subprocess.CalledProcessError) as e:
return f"ffmpeg failed: {e}"
return None
def _sanitize_character(part):
"""'.' separates the main sample from its variants, so it cannot appear in names."""
cleaned = re.sub(r'[^\w-]', '', str(part or '').strip())
return cleaned or "Voice"
def _sanitize_emotion(part):
cleaned = re.sub(r'[^\w-]+', '_', str(part or '').strip()).strip('_-').lower()
return cleaned or MAIN_VOICE
def voice_base_name(character, emotion):
"""`Arona` for the main voice, `Arona.happy` for a variant (ComfyUI-F5-TTS layout)."""
return character if emotion == MAIN_VOICE else f"{character}.{emotion}"
def _voice_targets(dest_dir, base_name):
"""Every file that belongs to one voice stem, including audio in other formats."""
targets = {ext: os.path.join(dest_dir, base_name + ext) for ext in AUDIO_EXTENSIONS}
targets[".txt"] = os.path.join(dest_dir, base_name + ".txt")
targets[".orig.txt"] = os.path.join(dest_dir, base_name + ".orig.txt")
return targets
def _commit_voice_files(plan):
"""Apply {final_path: staged_path_or_None} as one unit; restore previous files on failure."""
token = uuid.uuid4().hex[:8]
backups = {}
placed = []
try:
for final_path in plan:
if os.path.exists(final_path):
backup = f"{final_path}.bak-{token}"
os.replace(final_path, backup)
backups[final_path] = backup
for final_path, staged in plan.items():
if staged is not None:
os.replace(staged, final_path)
placed.append(final_path)
except Exception:
for final_path in placed:
try:
os.remove(final_path)
except OSError:
pass
for final_path, backup in backups.items():
try:
os.replace(backup, final_path)
except OSError:
pass
raise
for backup in backups.values():
try:
os.remove(backup)
except OSError:
pass
def _write_staged_text(dest_dir, text):
fd, path = tempfile.mkstemp(prefix=".voice-", suffix=".txt.tmp", dir=dest_dir)
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(text)
return path
def _ingest_voice(dest_dir, base_name, ext, audio_bytes, text, original_text, overwrite):
"""Stage every file first, then swap them in together so a failure keeps the previous voice."""
os.makedirs(dest_dir, exist_ok=True)
targets = _voice_targets(dest_dir, base_name)
existing = [path for path in targets.values() if os.path.exists(path)]
if existing and not overwrite:
return {"status": 409, "code": "exists", "error": f"{base_name} already exists"}
staged = []
try:
fd, raw_path = tempfile.mkstemp(prefix=".voice-", suffix=ext, dir=dest_dir)
staged.append(raw_path)
with os.fdopen(fd, 'wb') as f:
f.write(audio_bytes)
audio_path = raw_path
if ext != '.wav':
wav_path = raw_path[:-len(ext)] + '.wav'
staged.append(wav_path)
error = _convert_to_pcm_wav(raw_path, wav_path)
if error:
return {"status": 422, "code": "convert_failed", "error": f"Could not convert to WAV ({error}); existing files were kept"}
audio_path = wav_path
plan = {path: None for path in existing}
plan[targets['.wav']] = audio_path
if text:
text_path = _write_staged_text(dest_dir, text)
staged.append(text_path)
plan[targets['.txt']] = text_path
if original_text and original_text != text:
orig_path = _write_staged_text(dest_dir, original_text)
staged.append(orig_path)
plan[targets['.orig.txt']] = orig_path
_commit_voice_files(plan)
return {"status": 200, "filename": base_name + '.wav'}
finally:
for path in staged:
if os.path.exists(path):
try:
os.remove(path)
except OSError:
pass
async def api_upload_audio_voice(request):
"""POST /anomalous/upload_audio_voice - Ingest a voice sample with its transcript."""
try:
data = await request.post()
except Exception as e:
return web.json_response({"success": False, "error": f"Invalid multipart payload: {e}"}, status=400)
audio_field = data.get("audio")
if audio_field is None or not hasattr(audio_field, "file"):
return web.json_response({"success": False, "error": "Audio file is required"}, status=400)
ext = os.path.splitext(getattr(audio_field, "filename", "") or "")[1].lower()
if ext not in AUDIO_EXTENSIONS:
return web.json_response({
"success": False,
"error": f"Unsupported audio format '{ext}'. Supported: {', '.join(sorted(AUDIO_EXTENSIONS))}",
}, status=415)
audio_bytes = audio_field.file.read(MAX_UPLOAD_BYTES + 1)
if len(audio_bytes) > MAX_UPLOAD_BYTES:
return web.json_response({"success": False, "error": "Audio file exceeds 100MB limit"}, status=413)
character = _sanitize_character(data.get("character"))
emotion = _sanitize_emotion(data.get("emotion"))
if emotion in RESERVED_VOICE_NAMES:
return web.json_response({"success": False, "error": f"'{emotion}' is reserved"}, status=400)
text = str(data.get("text", "")).strip()
original_text = str(data.get("original_text", "")).strip()
overwrite = str(data.get("overwrite", "")).lower() in ("1", "true", "yes")
target_sub = str(data.get("target_subfolder", DEFAULT_VOICE_SUBFOLDER)).strip().strip("/\\")
input_dir = folder_paths.get_input_directory()
try:
dest_dir = resolve_within(input_dir, target_sub)
except ValueError:
return web.json_response({"success": False, "error": "Forbidden target folder"}, status=403)
if os.path.realpath(dest_dir) == os.path.realpath(input_dir):
target_sub = ""
base_name = voice_base_name(character, emotion)
try:
result = await asyncio.to_thread(_ingest_voice, dest_dir, base_name, ext, audio_bytes, text, original_text, overwrite)
except OSError as e:
return web.json_response({"success": False, "error": f"Failed to save voice: {e}"}, status=500)
if result["status"] != 200:
return web.json_response({"success": False, "code": result["code"], "error": result["error"]}, status=result["status"])
rel_prefix = os.path.relpath(dest_dir, input_dir).replace(os.sep, "/")
return web.json_response({
"success": True,
"slice": _slice_record(dest_dir, result["filename"], "" if rel_prefix == "." else rel_prefix),
"main_missing": emotion != MAIN_VOICE and not os.path.isfile(os.path.join(dest_dir, f"{character}.wav")),
})