From 5a5505487c40e375349164553f4f6cac1460a6eb Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 12 Oct 2026 10:47:32 +0200 Subject: [PATCH] feat: load NovelFull chapters directly for audiobooks --- README.md | 36 + __init__.py | 4 +- nodes/__init__.py | 3 +- nodes/web_novel_loader.py | 239 ++++ tests/test_web_novel_loader.py | 193 +++ workflows/novelfull_release_that_witch.json | 1326 +++++++++++++++++++ 6 files changed, 1799 insertions(+), 2 deletions(-) create mode 100644 nodes/web_novel_loader.py create mode 100644 tests/test_web_novel_loader.py create mode 100644 workflows/novelfull_release_that_witch.json diff --git a/README.md b/README.md index 12163b6..2f31001 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A ComfyUI custom node for [OmniVoice](https://github.com/k2-fsa/OmniVoice) — a - **Voice Presets** — built-in curated reference voices, ready to use without any audio file - **Voice Mixing** — blend two or three reference voices for a hybrid speaker - **EPUB Loader** — load chapters from an ebook directly into the pipeline +- **Web Novel Loader** — load NovelFull chapters directly from a novel URL, with local caching - **Audiobook-ready** — handles arbitrarily long text with near-constant VRAM via built-in chunking - **Multilingual** — 600+ languages @@ -125,6 +126,41 @@ Loads an EPUB file and outputs selected chapters as plain text, ready to pipe in **Outputs:** `text` (STRING) — selected chapters joined by `---`, `chapter_list` (STRING) — numbered list of all chapter titles for reference. +### OmniVoice Web Novel Loader + +Loads chapters directly from a NovelFull novel URL, such as +`https://novelfull.net/release-that-witch.html`. No EPUB conversion is needed. + +| Input | Description | +|-------|-------------| +| `novel_url` | NovelFull's main novel page, rather than an individual chapter page | +| `chapter_start` | First chapter in reading order, starting at 1 | +| `chapter_end` | Last chapter, inclusive; connect the same counter to both inputs for one chapter per audio file | +| `refresh_cache` | Re-download the index and selected chapters; leave off for normal batch generation | + +**Outputs:** `text`, `chapter_title`, `chapter_list` — the same outputs as the EPUB loader. +The body excludes the leading chapter heading and translator/editor credits, and filters +injected `ReadNovelFull.com` lines (including `freёReadNovelFull.com`). Connect +`chapter_title` to your existing title-and-body join to speak the title once. + +The first run reads all pages of the chapter index. Only the selected chapter bodies +are downloaded. The index and chapter text are cached under +`ComfyUI/models/omnivoice/web_novels/`, so previously loaded chapters can be reused +offline. A counter past the final chapter raises an error instead of repeating the +last chapter. Currently, the supported website is `novelfull.net`. + +The included `workflows/novelfull_release_that_witch.json` adapts the existing +chapter-by-chapter audiobook workflow: + +- An incrementing integer feeds both chapter inputs, starting at 1. +- The title is joined with the body once, preserving the existing text filters. +- The voice reference, generation settings, and FLAC output are preserved. +- The filename prefix follows `Release That Witch/ `. + +Restart ComfyUI after updating the extension, load the workflow, and set the integer +counter to your starting chapter. Queue one run per desired chapter. The reference +audio must still be available at the path selected by the Load Audio node. + ## Default Workflow A ready-to-use workflow is included at `workflows/voice_cloning.json`: diff --git a/__init__.py b/__init__.py index 264ed0b..9359b7b 100644 --- a/__init__.py +++ b/__init__.py @@ -1,9 +1,10 @@ -from .nodes import OmniVoiceModelLoader, OmniVoiceGenerate, OmniVoiceEpubLoader, OmniVoiceVoicePreset, OmniVoiceMixVoices, OmniVoiceVoiceDesign, OmniVoiceSpeaker, OmniVoiceSpeakers +from .nodes import OmniVoiceModelLoader, OmniVoiceGenerate, OmniVoiceEpubLoader, OmniVoiceWebNovelLoader, OmniVoiceVoicePreset, OmniVoiceMixVoices, OmniVoiceVoiceDesign, OmniVoiceSpeaker, OmniVoiceSpeakers NODE_CLASS_MAPPINGS = { "OmniVoiceModelLoader": OmniVoiceModelLoader, "OmniVoiceGenerate": OmniVoiceGenerate, "OmniVoiceEpubLoader": OmniVoiceEpubLoader, + "OmniVoiceWebNovelLoader": OmniVoiceWebNovelLoader, "OmniVoiceVoicePreset": OmniVoiceVoicePreset, "OmniVoiceMixVoices": OmniVoiceMixVoices, "OmniVoiceVoiceDesign": OmniVoiceVoiceDesign, @@ -15,6 +16,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "OmniVoiceModelLoader": "OmniVoice Model Loader", "OmniVoiceGenerate": "OmniVoice Generate", "OmniVoiceEpubLoader": "OmniVoice EPUB Loader", + "OmniVoiceWebNovelLoader": "OmniVoice Web Novel Loader", "OmniVoiceVoicePreset": "OmniVoice Voice Preset", "OmniVoiceMixVoices": "OmniVoice Mix Voices", "OmniVoiceVoiceDesign": "OmniVoice Voice Design", diff --git a/nodes/__init__.py b/nodes/__init__.py index 7db320c..379414c 100644 --- a/nodes/__init__.py +++ b/nodes/__init__.py @@ -1,9 +1,10 @@ from .loader import OmniVoiceModelLoader from .generator import OmniVoiceGenerate from .epub_loader import OmniVoiceEpubLoader +from .web_novel_loader import OmniVoiceWebNovelLoader from .voice_presets import OmniVoiceVoicePreset from .mix_voices import OmniVoiceMixVoices from .voice_design import OmniVoiceVoiceDesign from .multi_speaker import OmniVoiceSpeaker, OmniVoiceSpeakers -__all__ = ["OmniVoiceModelLoader", "OmniVoiceGenerate", "OmniVoiceEpubLoader", "OmniVoiceVoicePreset", "OmniVoiceMixVoices", "OmniVoiceVoiceDesign", "OmniVoiceSpeaker", "OmniVoiceSpeakers"] +__all__ = ["OmniVoiceModelLoader", "OmniVoiceGenerate", "OmniVoiceEpubLoader", "OmniVoiceWebNovelLoader", "OmniVoiceVoicePreset", "OmniVoiceMixVoices", "OmniVoiceVoiceDesign", "OmniVoiceSpeaker", "OmniVoiceSpeakers"] diff --git a/nodes/web_novel_loader.py b/nodes/web_novel_loader.py new file mode 100644 index 0000000..c9266e5 --- /dev/null +++ b/nodes/web_novel_loader.py @@ -0,0 +1,239 @@ +"""Load NovelFull chapters into the same text pipeline as the EPUB loader.""" + +import hashlib +import json +import logging +import os +from pathlib import Path +import re +import tempfile +import time +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qs, urljoin, urlsplit, urlunsplit +from urllib.request import Request, urlopen + +from bs4 import BeautifulSoup + +try: + import folder_paths + _CACHE_DIR = Path(folder_paths.models_dir) / "omnivoice" / "web_novels" +except ImportError: + _CACHE_DIR = Path.home() / ".cache" / "omnivoice" / "web_novels" + +_LOG = logging.getLogger(__name__) +_CHAPTER_HEADING = re.compile(r"^chapter\s*(\d+)\b", re.IGNORECASE) +# Match the site's injected footer, including its Cyrillic/Unicode spelling. +_WATERMARK = re.compile( + r"(?:fr[eеёéè]{2}\s*)?read\s*novel\s*full\s*\.\s*(?:com|net)", + re.IGNORECASE, +) +_CREDITS = re.compile(r"^(?:translator|editor|translation)\s*:", re.IGNORECASE) + + +def _validate_url(url): + parts = urlsplit(url.strip()) + if (parts.scheme not in {"http", "https"} + or parts.hostname not in {"novelfull.net", "www.novelfull.net"} + or parts.username or parts.password or parts.port not in {None, 80, 443}): + raise ValueError("Use a public NovelFull URL on https://novelfull.net/.") + return parts + + +def _novel_url(url): + parts = _validate_url(url) + if not re.fullmatch(r"/[^/]+\.html", parts.path): + raise ValueError("Paste the novel's chapter-list URL, not an individual chapter URL.") + # Always begin with the first page, even if the pasted URL has ?page=N. + return urlunsplit(("https", "novelfull.net", parts.path, "", "")) + + +def _fetch_html(url): + _validate_url(url) + for attempt in range(3): + try: + request = Request(url, headers={"User-Agent": "ComfyUI-OmniVoice/1.0"}) + with urlopen(request, timeout=20) as response: + _validate_url(response.geturl()) + return response.read().decode("utf-8", errors="replace") + except HTTPError as exc: + if exc.code in {429, 500, 502, 503, 504} and attempt < 2: + time.sleep(2 ** (attempt + 1)) + continue + raise RuntimeError(f"NovelFull returned HTTP {exc.code} for {url}.") from exc + except (URLError, TimeoutError, OSError) as exc: + if attempt < 2: + time.sleep(2 ** (attempt + 1)) + continue + raise RuntimeError(f"Could not download {url}: {exc}") from exc + + +def _read_cache(path): + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def _write_cache(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, + suffix=".tmp", delete=False) as handle: + temp_path = Path(handle.name) + try: + json.dump(value, handle, ensure_ascii=False) + except BaseException: + temp_path.unlink(missing_ok=True) + raise + try: + os.replace(temp_path, path) + finally: + temp_path.unlink(missing_ok=True) + + +def _parse_index(html, novel_url): + soup = BeautifulSoup(html, "html.parser") + container = soup.select_one("#list-chapter, .list-chapter") + if container is None: + raise ValueError("NovelFull's chapter list was not found. The page may be blocked or its layout changed.") + chapter_prefix = urlsplit(novel_url).path.removesuffix(".html") + "/" + chapters = [] + seen = set() + for anchor in container.select("a[href]"): + url = urljoin(novel_url, anchor["href"]) + parts = urlsplit(url) + if (parts.hostname != "novelfull.net" or not parts.path.startswith(chapter_prefix) + or not parts.path.endswith(".html")): + continue + url = urlunsplit(("https", "novelfull.net", parts.path, "", "")) + title = " ".join(anchor.get_text(" ", strip=True).split()) + if title and url not in seen: + seen.add(url) + chapters.append({"title": title, "url": url}) + if not chapters: + raise ValueError("NovelFull's chapter list is empty; no audio will be generated.") + + # New layout publishes the total; old layouts expose it through pagination. + pages = int(container.get("data-total-page", "1")) + for anchor in container.select("a[href]"): + parts = urlsplit(urljoin(novel_url, anchor["href"])) + if parts.hostname == "novelfull.net" and parts.path == urlsplit(novel_url).path: + number = parse_qs(parts.query).get("page", ["1"])[0] + if number.isdigit(): + pages = max(pages, int(number)) + if not 1 <= pages <= 1000: + raise ValueError("NovelFull reported an invalid number of chapter-list pages.") + return chapters, pages + + +def _load_index(novel_url, cache_dir, refresh): + path = cache_dir / "index.json" + cached = _read_cache(path) if not refresh else None + if isinstance(cached, list) and cached and all( + isinstance(ch, dict) and ch.get("title") and ch.get("url") for ch in cached): + return cached + + chapters, pages = _parse_index(_fetch_html(novel_url), novel_url) + seen = {ch["url"] for ch in chapters} + for page in range(2, pages + 1): + _LOG.info("OmniVoice: reading NovelFull chapter-list page %s/%s", page, pages) + time.sleep(0.25) + batch, _ = _parse_index(_fetch_html(f"{novel_url}?page={page}"), novel_url) + if not any(ch["url"] not in seen for ch in batch): + raise ValueError(f"NovelFull repeated chapter-list page {page}; the index is incomplete.") + for chapter in batch: + if chapter["url"] not in seen: + chapters.append(chapter) + seen.add(chapter["url"]) + _write_cache(path, chapters) + return chapters + + +def _parse_chapter(html, chapter): + soup = BeautifulSoup(html, "html.parser") + content = soup.select_one("#chapter-content, .chapter-c") + if content is None: + raise ValueError(f"Chapter text was not found at {chapter['url']}. The page may be blocked or its layout changed.") + for tag in content.select("script, style, iframe, ins, button, nav, .ads, .adsbygoogle, [hidden], h1, h2, h3"): + tag.decompose() + for tag in content.find_all(["p", "div", "br", "li"]): + tag.append(soup.new_string("\n\n")) + raw = content.get_text(separator="") + raw = _WATERMARK.sub("", raw).replace("\u200b", "") + paragraphs = [] + leading = True + title_number = _CHAPTER_HEADING.match(chapter["title"]) + for block in re.split(r"\n\s*\n", raw): + text = " ".join(block.split()) + if not text: + continue + heading = _CHAPTER_HEADING.match(text) + # Some chapters repeat the title with a different subtitle. Match the + # chapter number so the existing workflow can speak the title just once. + same_heading = (heading and title_number and heading[1] == title_number[1]) + if leading and (text.casefold() == chapter["title"].casefold() + or same_heading or _CREDITS.match(text)): + continue + leading = False + paragraphs.append(text) + text = "\n\n".join(paragraphs) + if not text: + raise ValueError(f"Chapter {chapter['title']} has no readable body text.") + return {"title": chapter["title"], "text": text} + + +def _load_chapter(chapter, cache_dir, refresh): + digest = hashlib.sha256(chapter["url"].encode()).hexdigest() + path = cache_dir / "chapters" / f"{digest}.json" + cached = _read_cache(path) if not refresh else None + if isinstance(cached, dict) and cached.get("text") and cached.get("title"): + return cached + result = _parse_chapter(_fetch_html(chapter["url"]), chapter) + _write_cache(path, result) + return result + + +class OmniVoiceWebNovelLoader: + @classmethod + def INPUT_TYPES(cls): + return {"required": { + "novel_url": ("STRING", { + "default": "https://novelfull.net/release-that-witch.html", + "tooltip": "NovelFull's novel URL. Downloads the chapter index once and caches selected chapter text locally.", + }), + "chapter_start": ("INT", { + "default": 1, "min": 1, "max": 99999, "step": 1, + "tooltip": "First chapter in reading order (1-indexed). Connect your incrementing chapter counter here.", + }), + "chapter_end": ("INT", { + "default": 1, "min": 1, "max": 99999, "step": 1, + "tooltip": "Last chapter, inclusive. Connect the same counter as chapter_start for one audio file per chapter.", + }), + "refresh_cache": ("BOOLEAN", { + "default": False, + "tooltip": "Re-download the chapter index and selected chapters. Leave off during batch generation.", + }), + }} + + RETURN_TYPES = ("STRING", "STRING", "STRING") + RETURN_NAMES = ("text", "chapter_title", "chapter_list") + FUNCTION = "load_novel" + CATEGORY = "OmniVoice" + + @classmethod + def IS_CHANGED(cls, novel_url, chapter_start, chapter_end, refresh_cache=False): + return float("nan") if refresh_cache else False + + def load_novel(self, novel_url, chapter_start, chapter_end, refresh_cache=False): + url = _novel_url(novel_url) + cache_dir = _CACHE_DIR / hashlib.sha256(url.encode()).hexdigest()[:16] + chapters = _load_index(url, cache_dir, refresh_cache) + if not 1 <= chapter_start <= len(chapters): + raise ValueError(f"Chapter {chapter_start} is outside this novel's range (1–{len(chapters)}). Stop the queue or change the counter.") + end = max(chapter_start, min(chapter_end, len(chapters))) + selected = [] + for chapter in chapters[chapter_start - 1:end]: + selected.append(_load_chapter(chapter, cache_dir, refresh_cache)) + text = "\n\n---\n\n".join(ch["text"] for ch in selected) + title = selected[0]["title"] + chapter_list = "\n".join(f"{i}. {ch['title']}" for i, ch in enumerate(chapters, 1)) + return text, title, chapter_list diff --git a/tests/test_web_novel_loader.py b/tests/test_web_novel_loader.py new file mode 100644 index 0000000..28600bb --- /dev/null +++ b/tests/test_web_novel_loader.py @@ -0,0 +1,193 @@ +from pathlib import Path +from unittest.mock import patch +from urllib.error import HTTPError + +import pytest + +from nodes import web_novel_loader as web + + +URL = "https://novelfull.net/example-book.html" + + +def index_page(numbers, pages=1): + links = "".join( + f'
  • Chapter {n}: Title {n}
  • ' + for n in numbers + ) + return ( + 'Latest chapter' + f'
      {links}
    ' + ) + + +def chapter_page(n): + return ( + f'

    Chapter {n}: Title {n}

    ' + f'

    Chapter {n}: Different title

    Translator: Someone

    ' + '

    Editor: Someone

    ' + f'

    Body {n} begins.

    freёReadNovelFull.com

    ' + '

    Body continues with emphasis.

    ' + '
    Comments
    ' + ) + + +def fake_site(): + return { + URL: index_page([1, 2], pages=2), + URL + "?page=2": index_page([3], pages=2), + **{f"https://novelfull.net/example-book/chapter-{n}.html": chapter_page(n) + for n in range(1, 4)}, + } + + +@pytest.fixture +def cached_site(tmp_path, monkeypatch): + monkeypatch.setattr(web, "_CACHE_DIR", tmp_path) + monkeypatch.setattr(web.time, "sleep", lambda _: None) + with patch.object(web, "_fetch_html", side_effect=fake_site().__getitem__) as fetch: + yield fetch + + +def test_paginated_index_selects_only_requested_chapter(cached_site): + text, title, chapter_list = web.OmniVoiceWebNovelLoader().load_novel(URL, 3, 3) + assert text == "Body 3 begins.\n\nBody continues with emphasis." + assert title == "Chapter 3: Title 3" + assert chapter_list.splitlines() == [ + "1. Chapter 1: Title 1", "2. Chapter 2: Title 2", "3. Chapter 3: Title 3", + ] + assert [call.args[0] for call in cached_site.call_args_list] == [ + URL, URL + "?page=2", "https://novelfull.net/example-book/chapter-3.html", + ] + + +def test_cached_chapter_can_be_loaded_offline(cached_site): + loader = web.OmniVoiceWebNovelLoader() + expected = loader.load_novel(URL, 2, 2) + cached_site.reset_mock() + cached_site.side_effect = RuntimeError("Offline") + assert web.OmniVoiceWebNovelLoader().load_novel(URL, 2, 2) == expected + cached_site.assert_not_called() + + +def test_counter_fetches_next_chapter_without_reloading_index(cached_site): + loader = web.OmniVoiceWebNovelLoader() + loader.load_novel(URL, 1, 1) + cached_site.reset_mock() + text, title, _ = loader.load_novel(URL, 2, 2) + assert "Body 2 begins." in text + assert title == "Chapter 2: Title 2" + cached_site.assert_called_once_with("https://novelfull.net/example-book/chapter-2.html") + + +def test_existing_title_join_speaks_title_once(cached_site): + body, title, _ = web.OmniVoiceWebNovelLoader().load_novel(URL, 1, 1) + spoken = title + " \n\n" + body.replace("freёReadNovelFull.com\n", "").replace("\\", "") + assert spoken.count("Chapter 1") == 1 + assert "Translator:" not in spoken + assert "Editor:" not in spoken + assert "NovelFull.com" not in spoken + assert "Next Chapter" not in spoken + assert "Comments" not in spoken + + +def test_range_and_end_below_start_match_epub_convention(cached_site): + loader = web.OmniVoiceWebNovelLoader() + text, _, _ = loader.load_novel(URL, 2, 999) + assert "Body 1" not in text + assert "Body 2" in text and "Body 3" in text + assert "\n\n---\n\n" in text + text, _, _ = loader.load_novel(URL, 2, 1) + assert "Body 2" in text and "Body 3" not in text + + +def test_counter_beyond_last_chapter_stops_instead_of_repeating(cached_site): + with pytest.raises(ValueError, match="outside this novel's range"): + web.OmniVoiceWebNovelLoader().load_novel(URL, 4, 4) + assert cached_site.call_count == 2 # index pages only + + +def test_refresh_redownloads_index_and_selected_chapter(cached_site): + loader = web.OmniVoiceWebNovelLoader() + loader.load_novel(URL, 1, 1) + cached_site.reset_mock() + loader.load_novel(URL, 1, 1, refresh_cache=True) + assert cached_site.call_count == 3 + + +def test_old_pagination_layout_and_duplicate_links(): + html = '''''' + chapters, pages = web._parse_index(html, URL) + assert pages == 3 + assert chapters == [{"title": "Chapter 1", "url": "https://novelfull.net/example-book/chapter-1.html"}] + + +def test_repeated_index_page_is_not_cached(tmp_path, monkeypatch): + monkeypatch.setattr(web.time, "sleep", lambda _: None) + with patch.object(web, "_fetch_html", return_value=index_page([1], pages=2)): + with pytest.raises(ValueError, match="index is incomplete"): + web._load_index(URL, tmp_path, False) + assert not (tmp_path / "index.json").exists() + + +@pytest.mark.parametrize("url", [ + "https://example.org/book.html", "file:///tmp/book.html", + "https://novelfull.net@example.org/book.html", "http://127.0.0.1/book.html", + "https://novelfull.net:9999/book.html", + "https://novelfull.net/example-book/chapter-1.html", +]) +def test_invalid_urls_are_rejected_before_network_access(url): + with patch.object(web, "_fetch_html") as fetch: + with pytest.raises(ValueError): + web.OmniVoiceWebNovelLoader().load_novel(url, 1, 1) + fetch.assert_not_called() + + +def test_pasted_page_url_starts_index_at_first_page(cached_site): + text, _, _ = web.OmniVoiceWebNovelLoader().load_novel(URL + "?page=2#list", 1, 1) + assert "Body 1 begins." in text + assert cached_site.call_args_list[0].args == (URL,) + + +def test_missing_or_empty_body_does_not_narrate_page_navigation(): + chapter = {"title": "Chapter 1", "url": URL} + for html in ["

    Blocked

    ", + '

    Chapter 1

    Translator: Someone

    ']: + with pytest.raises(ValueError): + web._parse_chapter(html, chapter) + + +def test_inline_breaks_ads_and_story_paragraphs_are_preserved(): + html = '''

    Chapter 1

    +

    First story paragraph.
    Second paragraph.

    + Ad +

    Translator: a character's job, inside the story.

    +

    Before freeReadNovelFull.com after.

    ''' + result = web._parse_chapter(html, {"title": "Chapter 1", "url": URL}) + assert result["text"] == ( + "First story paragraph.\n\nSecond paragraph.\n\n" + "Translator: a character's job, inside the story.\n\nBefore after." + ) + + +def test_http_block_is_actionable_and_not_retried(): + with patch.object(web, "urlopen", side_effect=HTTPError(URL, 403, "Forbidden", {}, None)) as request: + with pytest.raises(RuntimeError, match="HTTP 403"): + web._fetch_html(URL) + assert request.call_count == 1 + + +def test_transient_http_error_is_retried(monkeypatch): + monkeypatch.setattr(web.time, "sleep", lambda _: None) + with patch.object(web, "urlopen") as request: + response = request.return_value.__enter__.return_value + response.geturl.return_value = URL + response.read.return_value = b"Success" + request.side_effect = [HTTPError(URL, 503, "Unavailable", {}, None), request.return_value] + assert web._fetch_html(URL) == "Success" + assert request.call_count == 2 diff --git a/workflows/novelfull_release_that_witch.json b/workflows/novelfull_release_that_witch.json new file mode 100644 index 0000000..b33293e --- /dev/null +++ b/workflows/novelfull_release_that_witch.json @@ -0,0 +1,1326 @@ +{ + "id": "de2d6b02-9fc8-4e30-bace-d4890b30950b", + "revision": 0, + "last_node_id": 41, + "last_link_id": 85, + "nodes": [ + { + "id": 2, + "type": "OmniVoiceVoicePreset", + "pos": [ + 64, + 320 + ], + "size": [ + 320, + 128 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "ref_audio", + "type": "AUDIO", + "slot_index": 0, + "links": [] + }, + { + "name": "ref_text", + "type": "STRING", + "slot_index": 1, + "links": [] + } + ], + "properties": { + "aux_id": "ethanfel/ComfyUI-Omnivoice", + "ver": "9b62c9bda8469171025709e2fae3ce877ee0f059", + "Node name for S&R": "OmniVoiceVoicePreset", + "cnr_id": "comfyui-omnivoice-fel", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.8", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "Shadowheart – female, expressive (Chatterbox)" + ], + "widgets_values_named": { + "preset": "Shadowheart – female, expressive (Chatterbox)" + } + }, + { + "id": 25, + "type": "JoinStringMulti", + "pos": [ + 1600, + 192 + ], + "size": [ + 288, + 160 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "string_1", + "type": "STRING", + "link": 77 + }, + { + "name": "string_2", + "shape": 7, + "type": "STRING", + "link": 70 + } + ], + "outputs": [ + { + "name": "string", + "type": "STRING", + "links": [ + 52 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "068d4fee62d379723dd96dd3e768ed807f7d7135", + "Node name for S&R": "JoinStringMulti", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + 2, + " ", + false, + null + ], + "widgets_values_named": { + "inputcount": 2, + "delimiter": " ", + "return_list": false, + "Update inputs": null + } + }, + { + "id": 14, + "type": "OmniVoiceGenerate", + "pos": [ + 512, + 32 + ], + "size": [ + 384, + 448 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "OMNIVOICE_MODEL", + "link": 23 + }, + { + "name": "speakers", + "shape": 7, + "type": "OMNIVOICE_SPEAKERS", + "link": null + }, + { + "name": "ref_audio", + "shape": 7, + "type": "AUDIO", + "link": 66 + }, + { + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 65 + } + ], + "outputs": [ + { + "name": "audio", + "type": "AUDIO", + "links": [ + 31, + 80 + ] + } + ], + "properties": { + "aux_id": "git@192.168.1.1:Ethanfel/ComfyUI-Omnivoice.git", + "ver": "340c0aa402fdf8b56fad5eb1559ff901c7fc7cfc", + "Node name for S&R": "OmniVoiceGenerate", + "cnr_id": "comfyui-omnivoice-fel", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "Hello! This is a test of the OmniVoice text-to-speech system.", + "voice_cloning", + "You have acquired new skills, mechanic novice, basic maintenance, ▎ basic enhancement. As warmth surged through hand Zio's body, he ▎ realized to his surprise that he could actually comprehend the ▎ contents of the book now. ", + "", + 2, + 0.9, + 32, + 42, + "fixed" + ], + "widgets_values_named": { + "text": "Hello! This is a test of the OmniVoice text-to-speech system.", + "mode": "voice_cloning", + "ref_text": "You have acquired new skills, mechanic novice, basic maintenance, ▎ basic enhancement. As warmth surged through hand Zio's body, he ▎ realized to his surprise that he could actually comprehend the ▎ contents of the book now. ", + "instruct": "", + "guidance_scale": 2, + "speed": 0.9, + "num_step": 32, + "seed": 42, + "control_after_generate": "fixed" + } + }, + { + "id": 16, + "type": "OmniVoiceWebNovelLoader", + "pos": [ + 64, + 512 + ], + "size": [ + 288, + 190 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "chapter_start", + "type": "INT", + "widget": { + "name": "chapter_start" + }, + "link": 37 + }, + { + "name": "chapter_end", + "type": "INT", + "widget": { + "name": "chapter_end" + }, + "link": 38 + } + ], + "outputs": [ + { + "name": "text", + "type": "STRING", + "links": [ + 55 + ] + }, + { + "name": "chapter_title", + "type": "STRING", + "links": [ + 69, + 82 + ] + }, + { + "name": "chapter_list", + "type": "STRING", + "links": null + } + ], + "properties": { + "aux_id": "git@192.168.1.1:Ethanfel/ComfyUI-Omnivoice.git", + "Node name for S&R": "OmniVoiceWebNovelLoader", + "cnr_id": "comfyui-omnivoice-fel", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "https://novelfull.net/release-that-witch.html", + 1, + 1, + false + ], + "widgets_values_named": { + "novel_url": "https://novelfull.net/release-that-witch.html", + "chapter_start": 1, + "chapter_end": 1, + "refresh_cache": false + }, + "title": "OmniVoice Web Novel Loader" + }, + { + "id": 19, + "type": "LoadAudio", + "pos": [ + 32, + 768 + ], + "size": [ + 288, + 192 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "AUDIO", + "type": "AUDIO", + "links": [ + 66 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.18.1", + "Node name for S&R": "LoadAudio", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "audio/general/audiobook_reference_voice.wav", + null, + null, + null + ], + "widgets_values_named": { + "audio": "audio/general/audiobook_reference_voice.wav", + "upload": null, + "Rename / move file": null, + "Delete file": null + } + }, + { + "id": 33, + "type": "Text Multiline", + "pos": [ + -128, + 1376 + ], + "size": [ + 416, + 224 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [ + 73 + ] + } + ], + "properties": { + "cnr_id": "was-ns", + "ver": "3.0.1", + "Node name for S&R": "Text Multiline", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + " \n\n", + true + ], + "widgets_values_named": { + "text": " \n\n", + "dynamic_prompts": true + } + }, + { + "id": 23, + "type": "ComfyMathExpression", + "pos": [ + -96, + 1056 + ], + "size": [ + 416, + 224 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "label": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 41 + }, + { + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + } + ], + "outputs": [ + { + "name": "FLOAT", + "type": "FLOAT", + "links": null + }, + { + "name": "INT", + "type": "INT", + "links": [ + 75 + ] + }, + { + "name": "BOOL", + "type": "BOOLEAN", + "links": null + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.18.1", + "Node name for S&R": "ComfyMathExpression", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "a" + ], + "widgets_values_named": { + "expression": "a" + } + }, + { + "id": 35, + "type": "Any To String (mtb)", + "pos": [ + 352, + 1088 + ], + "size": [ + 192, + 32 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "input", + "type": "*", + "link": 75 + } + ], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [ + 76, + 77 + ] + } + ], + "properties": { + "cnr_id": "comfy-mtb", + "ver": "6d5fd743333ee144184d113da61df9f310b6b50c", + "Node name for S&R": "Any To String (mtb)" + } + }, + { + "id": 21, + "type": "ttN text3BOX_3WAYconcat", + "pos": [ + 1984, + 0 + ], + "size": [ + 416, + 352 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "name": "text2", + "type": "STRING", + "widget": { + "name": "text2" + }, + "link": 52 + } + ], + "outputs": [ + { + "name": "text1", + "type": "STRING", + "links": null + }, + { + "name": "text2", + "type": "STRING", + "links": null + }, + { + "name": "text3", + "type": "STRING", + "links": null + }, + { + "name": "1 & 2", + "type": "STRING", + "links": [ + 79, + 81 + ] + }, + { + "name": "1 & 3", + "type": "STRING", + "links": null + }, + { + "name": "2 & 3", + "type": "STRING", + "links": null + }, + { + "name": "concat", + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui_tinyterranodes", + "ver": "b5bb57f4b13463bd31355edf0aeb83c57ff795b3", + "Node name for S&R": "ttN text3BOX_3WAYconcat", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + }, + "ttNnodeVersion": "1.0.0" + }, + "widgets_values": [ + "Release That Witch", + "", + "", + "/" + ], + "widgets_values_named": { + "text1": "Release That Witch", + "text2": "", + "text3": "", + "delimiter": "/" + } + }, + { + "id": 39, + "type": "SaveAudioAdvanced", + "pos": [ + 2560, + 0 + ], + "size": [ + 288, + 160 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "name": "audio", + "type": "AUDIO", + "link": 80 + }, + { + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": 81 + } + ], + "outputs": [ + { + "name": "audio", + "type": "AUDIO", + "links": null + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.28.0" + }, + "widgets_values": [ + "audio/ComfyUI", + "flac" + ], + "widgets_values_named": { + "filename_prefix": "audio/ComfyUI", + "format": "flac" + } + }, + { + "id": 36, + "type": "Display Any (rgthree)", + "pos": [ + 576, + 1184 + ], + "size": [ + 224, + 96 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "dir": 3, + "name": "source", + "type": "*", + "link": 76 + } + ], + "outputs": [], + "properties": { + "cnr_id": "rgthree-comfy", + "ver": "27b4f4cdcf3b127c29d5d8135ac1536ecbd4c383", + "Node name for S&R": "Display Any (rgthree)" + }, + "widgets_values": [ + "" + ] + }, + { + "id": 15, + "type": "PreviewAudio", + "pos": [ + 1504, + 0 + ], + "size": [ + 384, + 96 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "name": "audio", + "type": "AUDIO", + "link": 31 + } + ], + "outputs": [ + { + "name": "audio", + "type": "AUDIO", + "links": null + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.18.1", + "Node name for S&R": "PreviewAudio", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [], + "widgets_values_named": {} + }, + { + "id": 1, + "type": "OmniVoiceModelLoader", + "pos": [ + 64, + 32 + ], + "size": [ + 320, + 160 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "model", + "type": "OMNIVOICE_MODEL", + "slot_index": 0, + "links": [ + 23 + ] + } + ], + "properties": { + "aux_id": "ethanfel/ComfyUI-Omnivoice", + "ver": "9b62c9bda8469171025709e2fae3ce877ee0f059", + "Node name for S&R": "OmniVoiceModelLoader", + "cnr_id": "comfyui-omnivoice-fel", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.8", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "cuda:0", + "bfloat16", + false + ], + "widgets_values_named": { + "device": "cuda:0", + "dtype": "bfloat16", + "compile": false + } + }, + { + "id": 31, + "type": "StringReplace", + "pos": [ + 1376, + 256 + ], + "size": [ + 416, + 224 + ], + "flags": { + "collapsed": true + }, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "string", + "type": "STRING", + "widget": { + "name": "string" + }, + "link": 83 + } + ], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [ + 70 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.19.0", + "Node name for S&R": "StringReplace", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "", + ":", + "" + ], + "widgets_values_named": { + "string": "", + "find": ":", + "replace": "" + } + }, + { + "id": 40, + "type": "StringReplace", + "pos": [ + 1152, + 256 + ], + "size": [ + 416, + 224 + ], + "flags": { + "collapsed": false + }, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "string", + "type": "STRING", + "widget": { + "name": "string" + }, + "link": 82 + } + ], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [ + 83 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.19.0", + "Node name for S&R": "StringReplace", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "", + "\\", + "" + ], + "widgets_values_named": { + "string": "", + "find": "\\", + "replace": "" + } + }, + { + "id": 29, + "type": "StringReplace", + "pos": [ + 512, + 544 + ], + "size": [ + 416, + 224 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "string", + "type": "STRING", + "widget": { + "name": "string" + }, + "link": 55 + } + ], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [ + 84 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.19.0", + "Node name for S&R": "StringReplace", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "", + "freёReadNovelFull.com\n", + "" + ], + "widgets_values_named": { + "string": "", + "find": "freёReadNovelFull.com\n", + "replace": "" + } + }, + { + "id": 41, + "type": "StringReplace", + "pos": [ + 1024, + 640 + ], + "size": [ + 416, + 224 + ], + "flags": { + "collapsed": true + }, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "string", + "type": "STRING", + "widget": { + "name": "string" + }, + "link": 84 + } + ], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [ + 85 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.19.0", + "Node name for S&R": "StringReplace", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "", + "\\", + "" + ], + "widgets_values_named": { + "string": "", + "find": "\\", + "replace": "" + } + }, + { + "id": 38, + "type": "Display Any (rgthree)", + "pos": [ + 2560, + 480 + ], + "size": [ + 288, + 160 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "dir": 3, + "name": "source", + "type": "*", + "link": 79 + } + ], + "outputs": [], + "properties": { + "cnr_id": "rgthree-comfy", + "ver": "27b4f4cdcf3b127c29d5d8135ac1536ecbd4c383", + "Node name for S&R": "Display Any (rgthree)" + }, + "widgets_values": [ + "" + ] + }, + { + "id": 22, + "type": "PrimitiveInt", + "pos": [ + -576, + 608 + ], + "size": [ + 288, + 96 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "INT", + "type": "INT", + "links": [ + 37, + 38, + 41 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.18.1", + "Node name for S&R": "PrimitiveInt", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + 1, + "increment" + ], + "widgets_values_named": { + "value": 1, + "control_after_generate": "increment" + } + }, + { + "id": 30, + "type": "JoinStringMulti", + "pos": [ + 576, + 864 + ], + "size": [ + 288, + 192 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "string_1", + "type": "STRING", + "link": 69 + }, + { + "name": "string_2", + "shape": 7, + "type": "STRING", + "link": 73 + }, + { + "name": "string_3", + "shape": 7, + "type": "STRING", + "link": 85 + } + ], + "outputs": [ + { + "name": "string", + "type": "STRING", + "links": [ + 65, + 78 + ] + } + ], + "properties": { + "cnr_id": "comfyui-kjnodes", + "ver": "52807058a17532dd3d03f2143fb4c52beab5f936", + "Node name for S&R": "JoinStringMulti", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + 3, + "", + false, + null + ], + "widgets_values_named": { + "inputcount": 3, + "delimiter": "", + "return_list": false, + "Update inputs": null + } + }, + { + "id": 37, + "type": "Display Any (rgthree)", + "pos": [ + 992, + 896 + ], + "size": [ + 448, + 800 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "dir": 3, + "name": "source", + "type": "*", + "link": 78 + } + ], + "outputs": [], + "properties": { + "cnr_id": "rgthree-comfy", + "ver": "27b4f4cdcf3b127c29d5d8135ac1536ecbd4c383", + "Node name for S&R": "Display Any (rgthree)" + }, + "widgets_values": [ + "" + ] + } + ], + "links": [ + [ + 23, + 1, + 0, + 14, + 0, + "OMNIVOICE_MODEL" + ], + [ + 31, + 14, + 0, + 15, + 0, + "AUDIO" + ], + [ + 37, + 22, + 0, + 16, + 0, + "INT" + ], + [ + 38, + 22, + 0, + 16, + 1, + "INT" + ], + [ + 52, + 25, + 0, + 21, + 0, + "STRING" + ], + [ + 55, + 16, + 0, + 29, + 0, + "STRING" + ], + [ + 65, + 30, + 0, + 14, + 3, + "STRING" + ], + [ + 66, + 19, + 0, + 14, + 2, + "AUDIO" + ], + [ + 69, + 16, + 1, + 30, + 0, + "STRING" + ], + [ + 70, + 31, + 0, + 25, + 1, + "STRING" + ], + [ + 73, + 33, + 0, + 30, + 1, + "STRING" + ], + [ + 75, + 23, + 1, + 35, + 0, + "INT" + ], + [ + 76, + 35, + 0, + 36, + 0, + "STRING" + ], + [ + 77, + 35, + 0, + 25, + 0, + "STRING" + ], + [ + 78, + 30, + 0, + 37, + 0, + "STRING" + ], + [ + 79, + 21, + 3, + 38, + 0, + "STRING" + ], + [ + 82, + 16, + 1, + 40, + 0, + "STRING" + ], + [ + 83, + 40, + 0, + 31, + 0, + "STRING" + ], + [ + 84, + 29, + 0, + 41, + 0, + "STRING" + ], + [ + 85, + 41, + 0, + 30, + 2, + "STRING" + ], + [ + 41, + 22, + 0, + 23, + 0, + "INT" + ], + [ + 80, + 14, + 0, + 39, + 0, + "AUDIO" + ], + [ + 81, + 21, + 3, + 39, + 1, + "STRING" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.5989500000000078, + "offset": [ + 1783.6153523867024, + 306.46802133186867 + ] + }, + "ue_links": [], + "links_added_by_ue": [], + "frontendVersion": "1.57.0", + "VHS_latentpreview": true, + "VHS_latentpreviewrate": 0, + "VHS_MetadataImage": true, + "VHS_KeepIntermediate": true + }, + "version": 0.4 +}