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
136 lines
4.4 KiB
Python
136 lines
4.4 KiB
Python
"""Read ComfyUI generation info embedded in saved audio (FLAC and Ogg/Opus comments).
|
|
|
|
ComfyUI's audio save/preview nodes store the API prompt as a `prompt` comment.
|
|
Only the fields the audio gallery shows are extracted; nothing is written.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
|
|
MAX_TAG_BYTES = 16 * 1024 * 1024
|
|
MAX_OGG_SCAN_BYTES = 32 * 1024 * 1024
|
|
SPEECH_INPUTS = ("speech", "text", "gen_text")
|
|
SAMPLE_INPUTS = ("sample", "ref_audio", "audio_prompt")
|
|
|
|
|
|
def _parse_vorbis_comment(data, offset=0):
|
|
"""Parse a Vorbis comment block into {lowercase key: value}."""
|
|
tags = {}
|
|
try:
|
|
vendor_len = int.from_bytes(data[offset:offset + 4], "little")
|
|
pos = offset + 4 + vendor_len
|
|
count = int.from_bytes(data[pos:pos + 4], "little")
|
|
pos += 4
|
|
for _ in range(min(count, 1024)):
|
|
length = int.from_bytes(data[pos:pos + 4], "little")
|
|
pos += 4
|
|
entry = data[pos:pos + length].decode("utf-8", "replace")
|
|
pos += length
|
|
key, sep, value = entry.partition("=")
|
|
if sep:
|
|
tags.setdefault(key.lower(), value)
|
|
except (IndexError, ValueError):
|
|
pass
|
|
return tags
|
|
|
|
|
|
def _flac_tags(handle):
|
|
if handle.read(4) != b"fLaC":
|
|
return {}
|
|
while True:
|
|
header = handle.read(4)
|
|
if len(header) < 4:
|
|
return {}
|
|
is_last = header[0] & 0x80
|
|
block_type = header[0] & 0x7F
|
|
length = int.from_bytes(header[1:4], "big")
|
|
if block_type == 4:
|
|
if length > MAX_TAG_BYTES:
|
|
return {}
|
|
return _parse_vorbis_comment(handle.read(length))
|
|
handle.seek(length, os.SEEK_CUR)
|
|
if is_last:
|
|
return {}
|
|
|
|
|
|
def _ogg_packets(handle, wanted=2):
|
|
"""Yield the first `wanted` logical packets of an Ogg stream."""
|
|
packet = b""
|
|
scanned = 0
|
|
found = 0
|
|
while found < wanted and scanned < MAX_OGG_SCAN_BYTES:
|
|
header = handle.read(27)
|
|
if len(header) < 27 or header[:4] != b"OggS":
|
|
return
|
|
segments = header[26]
|
|
lacing = handle.read(segments)
|
|
scanned += 27 + segments
|
|
for size in lacing:
|
|
packet += handle.read(size)
|
|
scanned += size
|
|
if size < 255:
|
|
yield packet
|
|
packet = b""
|
|
found += 1
|
|
if found >= wanted:
|
|
return
|
|
if len(packet) > MAX_TAG_BYTES:
|
|
return
|
|
|
|
|
|
def _ogg_tags(handle):
|
|
packets = list(_ogg_packets(handle, wanted=2))
|
|
if len(packets) < 2:
|
|
return {}
|
|
comment = packets[1]
|
|
if comment.startswith(b"OpusTags"):
|
|
return _parse_vorbis_comment(comment, 8)
|
|
if comment.startswith(b"\x03vorbis"):
|
|
return _parse_vorbis_comment(comment, 7)
|
|
return {}
|
|
|
|
|
|
def read_audio_tags(path):
|
|
"""Return {lowercase key: value} comments for FLAC/Ogg/Opus files, else {}."""
|
|
ext = os.path.splitext(path)[1].lower()
|
|
try:
|
|
with open(path, "rb") as handle:
|
|
if ext == ".flac":
|
|
return _flac_tags(handle)
|
|
if ext in (".ogg", ".opus"):
|
|
return _ogg_tags(handle)
|
|
except OSError:
|
|
pass
|
|
return {}
|
|
|
|
|
|
def generation_info(path):
|
|
"""Speech text, sample voice and seed of the TTS node that produced this file, if recorded."""
|
|
raw = read_audio_tags(path).get("prompt")
|
|
if not raw:
|
|
return None
|
|
try:
|
|
prompt = json.loads(raw)
|
|
except ValueError:
|
|
return None
|
|
if not isinstance(prompt, dict):
|
|
return None
|
|
|
|
nodes = [node for node in prompt.values() if isinstance(node, dict) and isinstance(node.get("inputs"), dict)]
|
|
# Prefer TTS nodes; otherwise any node with a speech-like text input.
|
|
nodes.sort(key=lambda node: "tts" not in str(node.get("class_type", "")).lower())
|
|
for node in nodes:
|
|
inputs = node["inputs"]
|
|
speech = next((inputs[key] for key in SPEECH_INPUTS if isinstance(inputs.get(key), str) and inputs[key].strip()), None)
|
|
if speech is None:
|
|
continue
|
|
sample = next((inputs[key] for key in SAMPLE_INPUTS if isinstance(inputs.get(key), str)), None)
|
|
seed = inputs.get("seed")
|
|
return {
|
|
"node_type": str(node.get("class_type") or ""),
|
|
"speech": speech[:4000],
|
|
"sample": sample.replace("\\", "/") if sample else None,
|
|
"seed": seed if isinstance(seed, (int, float)) else None,
|
|
}
|
|
return None
|