Files
DemonGatanjieu-Anomalous_Mo…/api/audio_metadata.py
T
DemonGatanjieuandClaude Opus 5.5 634b2384cf feat(audio): engine switch with runtime detection; GPT-SoVITS via Anomalous_TTS
- audio_engines.js: F5-TTS and GPT-SoVITS (the separate Anomalous_TTS node
  pack), detected through /object_info/<node class>. Both engines' voices are
  normalised into one voice-group shape so cards, sidebar and Script Director
  share one implementation.
- The audio page shows an engine switch; a missing engine stays listed as
  "not installed" with install steps (no popups). The F5-TTS library still
  works without the F5-TTS node; GPT-SoVITS is never scanned when missing.
- GPT-SoVITS cards come only from the node's HTTP contract: preview reference
  clips, drag a character onto AnomalousTTS_CharacterSpeech, and edit the
  emotion references ("Edit emotions"), saved through /anomalous_tts/settings
  with unknown fields preserved and the node's validation messages shown.
- Drop targets and the Script Director are per engine: voices of one engine
  are refused on the other engine's node with the reason.
- Audio gallery reads the character of GPT-SoVITS generations.
- Usage guide (in-app and docs/guides/audio-studio.md) covers both engines.

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

137 lines
4.5 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")
# "character": Anomalous_TTS (GPT-SoVITS) names the voice by character instead of a file.
SAMPLE_INPUTS = ("sample", "ref_audio", "audio_prompt", "character")
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