mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
023884d6d8 | ||
|
|
aa98547456 | ||
|
|
528343f375 | ||
|
|
7fa8b23c4e | ||
|
|
3171066f0c | ||
|
|
c1c93ea00a | ||
|
|
795dee84ad | ||
|
|
eb4575df23 | ||
|
|
f58b254aed | ||
|
|
e073c32e64 | ||
|
|
509477bafa | ||
|
|
867d373eb9 | ||
|
|
f727c5ca70 | ||
|
|
e48df7c106 | ||
|
|
395cd75038 | ||
|
|
e60a1f7e42 | ||
|
|
1fe5a73c98 | ||
|
|
37db948d2f | ||
|
|
63679405a9 | ||
|
|
156fa75c76 | ||
|
|
c7631cc852 | ||
|
|
6f09efd018 | ||
|
|
f1903eb6ac | ||
|
|
977b2a736c | ||
|
|
1f51e3edd3 | ||
|
|
5f5dc85ae0 |
@@ -5,7 +5,7 @@ on:
|
||||
#release:
|
||||
# types: [published]
|
||||
# Build only
|
||||
#push:
|
||||
#push: it
|
||||
# branches: [main]
|
||||
# TODO - enable build on pull requests if build times can be reduced
|
||||
# pull_request:
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
- name: Login to Github Container Registry
|
||||
# Only if we need to push an image
|
||||
if: ${{ github.event_name == 'release' && github.event.action == 'published' }}
|
||||
# if: ${{ github.event_name == 'release' && github.event.action == 'published' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -51,11 +51,13 @@ jobs:
|
||||
context: ./abogen
|
||||
file: ./abogen/Dockerfile
|
||||
# platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64 # using the solution mentioned in https://github.com/denizsafak/abogen/issues/46
|
||||
# Only push if we are publishing a release
|
||||
push: ${{ github.event_name == 'release' && github.event.action == 'published' }}
|
||||
# push: ${{ github.event_name == 'release' && github.event.action == 'published' }}
|
||||
push: true
|
||||
# Use a 'temp' tag, that won't be pushed, for non-release builds
|
||||
tags: ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name || 'temp' }}
|
||||
tags: ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name || 'latest' }}
|
||||
# Use a cache to reduce build times
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=gha
|
||||
|
||||
+26
-2
@@ -1,5 +1,29 @@
|
||||
# 1.1.7
|
||||
- Added MPS GPU acceleration support for Silicon Mac, mentioned in https://github.com/denizsafak/abogen/issues/32#issuecomment-3155902040 by @jefro108. **Please read the [Mac](https://github.com/denizsafak/abogen?tab=readme-ov-file#mac) section in the documentation again, as it requires additional configuration.**
|
||||
- Added word-by-word karaoke highlighting feature by @robmckinnon in PR #65
|
||||
- Fixed sleep inhibition error occurring on some Linux systems that do not use systemd, mentioned in #67 by @hendrack
|
||||
- Improvements in code and documentation.
|
||||
|
||||
# 1.1.6
|
||||
- Improved EPUB chapter detection: Now reliably detects chapters from NAV HTML (TOC) files, even in non-standard EPUBs, fixes the issue mentioned by @jefro108 in #33
|
||||
- Fixed SRT subtitle numbering issue, mentioned by @page-muncher in #41
|
||||
- Fixed missing chapter contents issue in some EPUB files.
|
||||
- Windows installer script now prompts the user to install the CUDA version of PyTorch even if no NVIDIA GPU is detected.
|
||||
- Abogen now includes Mandarin Chinese (misaki[zh]) by default; manual installation is no longer required.
|
||||
|
||||
# 1.1.5
|
||||
- Changed the temporary directory path to user's cache directory, which is more appropriate for storing cache files and avoids issues with unintended cleanup.
|
||||
- Fixed the isssue where extra metadata information was not being saved to M4B files when they have no chapters, ensuring that all metadata is correctly written to the output file.
|
||||
- Fixed sleep prevention process not ending if program exited using Ctrl+C or kill.
|
||||
- Improved automatic filename suffixing to better prevent overwriting files with the same name, even if they have different extensions.
|
||||
- Improvements in code and documentation.
|
||||
|
||||
# 1.1.4
|
||||
- Fixed extra metadata information not being saved to M4B files, ensuring that all metadata is correctly written to the output file.
|
||||
- Reformatted the code using Black for better readability and consistency.
|
||||
|
||||
# 1.1.3
|
||||
- `M4B (with chapters)` generation is faster now, as it directly generates `m4b` files instead of converting from `wav`, which significantly reduces processing time.
|
||||
- `M4B (with chapters)` generation is faster now, as it directly generates `m4b` files instead of converting from `wav`, which significantly reduces processing time, fixes the issue mentioned by @Milor123 in #39
|
||||
- Better sleep state handling for Linux.
|
||||
- The app window now tries to fit the screen if its height would exceed the available display area.
|
||||
- Fixed issue where the app would not restart properly on Windows.
|
||||
@@ -12,7 +36,7 @@
|
||||
- Added a new option: `Reset to default settings`, allowing users to reset all settings to their default values.
|
||||
- Added a new option: `Disable Kokoro's internet access`. This lets you prevent Kokoro from downloading models or voices from HuggingFace Hub, which can help avoid long waiting times if your computer is offline.
|
||||
- HuggingFace Hub telemetry is now disabled by default for improved privacy. (HuggingFace Hub is used by Kokoro to download its models)
|
||||
- Potential fix for #37 and #38, where the program was becoming slow while processing large files.
|
||||
- cPotential fix for #37 and #38, where the program was becoming slow while processing large files.
|
||||
- Fixed `Open folder` and `Open file` buttons in the queue manager GUI.
|
||||
- Improvements in code structure.
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ Abogen is a powerful text-to-speech conversion tool that makes it easy to turn e
|
||||
<img title="Abogen Main" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen.png' width="380"> <img title="Abogen Processing" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen2.png' width="380">
|
||||
|
||||
## Demo
|
||||
https://github.com/user-attachments/assets/cb66512d-0a52-48c3-bda4-f1e6a03fb8d6
|
||||
|
||||
https://github.com/user-attachments/assets/094ba3df-7d66-494a-bc31-0e4b41d0b865
|
||||
|
||||
> This demo was generated in just 5 seconds, producing ∼1 minute of audio with perfectly synced subtitles. To create a similar video, see [the demo guide](https://github.com/denizsafak/abogen/tree/main/demo).
|
||||
|
||||
@@ -60,6 +61,10 @@ source venv/bin/activate
|
||||
|
||||
# Install abogen
|
||||
pip3 install abogen
|
||||
|
||||
# For Silicon Mac (M1, M2 etc.)
|
||||
# After installing abogen, we need to install Kokoro's development version which includes MPS support.
|
||||
pip3 install git+https://github.com/hexgrad/kokoro.git
|
||||
```
|
||||
### Linux
|
||||
```bash
|
||||
@@ -129,7 +134,7 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex
|
||||
| **Select Voice** | First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female. |
|
||||
| **Voice mixer** | Create custom voices by mixing different voice models with a profile system. See [Voice Mixer](#voice-mixer) for more details. |
|
||||
| **Voice preview** | Listen to the selected voice before processing. |
|
||||
| **Generate subtitles** | `Disabled`, `Sentence`, `Sentence + Comma`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry) |
|
||||
| **Generate subtitles** | `Disabled`, `Sentence`, `Sentence + Comma`, `Sentence + Highlighting`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry) |
|
||||
| **Output voice format** | `.WAV`, `.FLAC`, `.MP3`, `.OPUS (best compression)` and `M4B (with chapters)` (Special thanks to [@jborza](https://github.com/jborza) for chapter support in PR [#10](https://github.com/denizsafak/abogen/pull/10)) |
|
||||
| **Output subtitle format** | Configures the subtitle format as `SRT (standard)`, `ASS (wide)`, `ASS (narrow)`, `ASS (centered wide)`, or `ASS (centered narrow)`. |
|
||||
| **Replace single newlines with spaces** | Replaces single newlines with spaces in the text. This is useful for texts that have imaginary line breaks. |
|
||||
@@ -149,13 +154,15 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex
|
||||
| **Configure max lines in log window** | Configures the maximum number of lines to display in the log window. |
|
||||
| **Separate chapters audio format** | Configures the audio format for separate chapters as `wav`, `flac`, `mp3`, or `opus`. |
|
||||
| **Create desktop shortcut** | Creates a shortcut on your desktop for easy access. |
|
||||
| **Open config.json directory** | Opens the directory where the configuration file is stored. |
|
||||
| **Open temp directory** | Opens the temporary directory where converted text files are stored. |
|
||||
| **Clear temporary files** | Deletes temporary files created during the conversion or preview. |
|
||||
| **Open config directory** | Opens the directory where the configuration file is stored. |
|
||||
| **Open cache directory** | Opens the cache directory where converted text files are stored. |
|
||||
| **Clear cache files** | Deletes cache files created during the conversion or preview. |
|
||||
| **Check for updates at startup** | Automatically checks for updates when the program starts. |
|
||||
| **Disable Kokoro's internet access** | Prevents Kokoro from downloading models or voices from HuggingFace Hub, useful for offline use. |
|
||||
| **Reset to default settings** | Resets all settings to their default values. |
|
||||
|
||||
> Special thanks to [@robmckinnon](https://github.com/robmckinnon) for adding Sentence + Highlighting feature in PR [#65](https://github.com/denizsafak/abogen/pull/65)
|
||||
|
||||
## `Voice Mixer`
|
||||
<img title="Abogen Voice Mixer" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/voice_mixer.png'>
|
||||
|
||||
@@ -174,7 +181,7 @@ Abogen will process each item in the queue automatically, saving outputs as conf
|
||||
> Special thanks to [@jborza](https://github.com/jborza) for adding queue mode in PR [#35](https://github.com/denizsafak/abogen/pull/35)
|
||||
|
||||
## `About Chapter Markers`
|
||||
When you process ePUB or PDF files, Abogen converts them into text files stored in your temporary directory. When you click "Edit," you're actually modifying these converted text files. In these text files, you'll notice tags that look like this:
|
||||
When you process ePUB or PDF files, Abogen converts them into text files stored in your cache directory. When you click "Edit," you're actually modifying these converted text files. In these text files, you'll notice tags that look like this:
|
||||
|
||||
```
|
||||
<<CHAPTER_MARKER:Chapter Title>>
|
||||
@@ -221,6 +228,9 @@ Similar to chapter markers, it is possible to add metadata tags for `M4B` files.
|
||||
```
|
||||
For a complete list of supported languages and voices, refer to Kokoro's [VOICES.md](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/VOICES.md). To listen to sample audio outputs, see [SAMPLES.md](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/SAMPLES.md).
|
||||
|
||||
> [!NOTE]
|
||||
> Japanese audio may require additional configuration. Please check [#56](https://github.com/denizsafak/abogen/issues/56) for more information.
|
||||
|
||||
## `MPV Config`
|
||||
I highly recommend using [MPV](https://mpv.io/installation/) to play your audio files, as it supports displaying subtitles even without a video track. Here's my `mpv.conf`:
|
||||
```
|
||||
@@ -270,7 +280,7 @@ Abogen launches automatically inside the container.
|
||||
|
||||
Known issues:
|
||||
- Audio preview is not working inside container (ALSA error).
|
||||
- `Open temp directory` and `Open configuration directory` options in settings not working. (Tried pcmanfm, did not work with Abogen).
|
||||
- `Open cache directory` and `Open configuration directory` options in settings not working. (Tried pcmanfm, did not work with Abogen).
|
||||
|
||||
(Special thanks to [@geo38](https://www.reddit.com/user/geo38/) from Reddit, who provided the Dockerfile and instructions in [this comment](https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/).)
|
||||
|
||||
|
||||
+23
-4
@@ -1,5 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
setlocal EnableDelayedExpansion
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: Set misaki language
|
||||
@@ -23,7 +23,6 @@ set CUDA_VERSION=128
|
||||
for /f "delims=: tokens=*" %%A in ('findstr /b ::: "%~f0"') do @echo(%%A
|
||||
|
||||
set CURRENT_DIR="%CD%"
|
||||
setlocal enabledelayedexpansion
|
||||
set NAME=abogen
|
||||
set PROJECTFOLDER=abogen
|
||||
set RUN=python_embedded\Scripts\abogen.exe
|
||||
@@ -264,7 +263,7 @@ if exist %PYPROJECT_FILE% (
|
||||
:: Install misaki again if MISAKI_LANG is not set to "en"
|
||||
if "%MISAKI_LANG%" NEQ "en" (
|
||||
echo Configuring language pack: %MISAKI_LANG%
|
||||
%PYTHON_CONSOLE_PATH% -m pip install misaki[lang] --upgrade --no-warn-script-location
|
||||
%PYTHON_CONSOLE_PATH% -m pip install misaki[%MISAKI_LANG%] --upgrade --no-warn-script-location
|
||||
if errorlevel 1 (
|
||||
echo Failed to install misaki language pack.
|
||||
pause
|
||||
@@ -276,6 +275,7 @@ if "%MISAKI_LANG%" NEQ "en" (
|
||||
for /f %%i in ('%PYTHON_CONSOLE_PATH% -c "from abogen.is_nvidia import check; print(check())"') do set IS_NVIDIA=%%i
|
||||
|
||||
:: Check if torch is installed with CUDA support
|
||||
echo.
|
||||
echo Checking CUDA availability...
|
||||
if /I "%IS_NVIDIA%"=="true" (
|
||||
for /f %%i in ('%PYTHON_CONSOLE_PATH% -c "from torch.cuda import is_available; print(is_available())"') do set cuda_available=%%i
|
||||
@@ -293,7 +293,26 @@ if /I "%IS_NVIDIA%"=="true" (
|
||||
echo CUDA is available on NVIDIA GPU.
|
||||
)
|
||||
) else (
|
||||
echo GPU is not NVIDIA. Skipping PyTorch CUDA installation.
|
||||
echo.
|
||||
echo Unable to detect an NVIDIA GPU in your system.
|
||||
echo.
|
||||
echo Do you want to install PyTorch anyway?
|
||||
echo.
|
||||
echo If you DO have an NVIDIA GPU, please press Y.
|
||||
echo If you DO NOT have an NVIDIA GPU, please press N.
|
||||
echo.
|
||||
choice /C YN /M "Y=Yes, N=No"
|
||||
if errorlevel 2 (
|
||||
echo Skipping PyTorch installation.
|
||||
) else (
|
||||
echo Installing PyTorch with CUDA %CUDA_VERSION% support...
|
||||
%PYTHON_CONSOLE_PATH% -m pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu%CUDA_VERSION% --no-warn-script-location
|
||||
if errorlevel 1 (
|
||||
echo Failed to install PyTorch.
|
||||
pause
|
||||
exit /b
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
:: Ask user if they want to create a desktop shortcut
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.1.3
|
||||
1.1.7
|
||||
+64
-53
@@ -324,14 +324,31 @@ class HandlerDialog(QDialog):
|
||||
f"Found NCX item via ITEM_NAVIGATION: {ncx_in_nav.get_name()}"
|
||||
)
|
||||
|
||||
# 3. If still no nav_item, check for ITEM_NCX directly
|
||||
if not nav_item:
|
||||
ncx_items = list(self.book.get_items_of_type(ebooklib.ITEM_NCX))
|
||||
# 3. If still no nav_item, check for NCX or fallback to NAV HTML in all ITEM_DOCUMENTs
|
||||
ncx_constant = getattr(epub, "ITEM_NCX", None)
|
||||
if not nav_item and ncx_constant is not None:
|
||||
ncx_items = list(self.book.get_items_of_type(ncx_constant))
|
||||
if ncx_items:
|
||||
nav_item = ncx_items[0] # Take the first one
|
||||
nav_item = ncx_items[0]
|
||||
nav_type = "ncx"
|
||||
logging.info(f"Found NCX item via ITEM_NCX: {ncx_items[0].get_name()}")
|
||||
|
||||
logging.info(f"Found NCX item via ITEM_NCX: {nav_item.get_name()}")
|
||||
# Fallback: search all ITEM_DOCUMENTs for a NAV HTML with <nav epub:type="toc">
|
||||
if not nav_item:
|
||||
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
|
||||
try:
|
||||
html_content = item.get_content().decode("utf-8", errors="ignore")
|
||||
if "<nav" in html_content and 'epub:type="toc"' in html_content:
|
||||
soup = BeautifulSoup(html_content, "html.parser")
|
||||
nav_tag = soup.find("nav", attrs={"epub:type": "toc"})
|
||||
if nav_tag:
|
||||
nav_item = item
|
||||
nav_type = "html"
|
||||
logging.info(
|
||||
f"Found NAV HTML with TOC in: {item.get_name()}"
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
continue
|
||||
# 4. If no navigation item found by any method, trigger fallback
|
||||
if not nav_item or not nav_type:
|
||||
logging.warning(
|
||||
@@ -516,10 +533,14 @@ class HandlerDialog(QDialog):
|
||||
slice_html += self.doc_content.get(intermediate_doc_href, "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: if slice_html is empty, try to get the whole file's text
|
||||
if not slice_html.strip() and current_doc_html:
|
||||
logging.warning(
|
||||
f"No content found for src '{current_src}', using full file as fallback."
|
||||
)
|
||||
slice_html = current_doc_html
|
||||
if slice_html.strip():
|
||||
slice_soup = BeautifulSoup(slice_html, "html.parser")
|
||||
# Add double newlines after <p> and <div> tags
|
||||
for tag in slice_soup.find_all(["p", "div"]):
|
||||
tag.append("\n\n")
|
||||
for tag in slice_soup.find_all(["sup", "sub"]):
|
||||
@@ -581,6 +602,23 @@ class HandlerDialog(QDialog):
|
||||
f"Finished processing EPUB navigation. Found {len(self.content_texts)} content sections linked to TOC."
|
||||
)
|
||||
|
||||
def _find_doc_key(self, base_href, doc_order, doc_order_decoded):
|
||||
"""Find the best matching doc_key for a given base_href using robust matching."""
|
||||
candidates = [
|
||||
base_href,
|
||||
urllib.parse.unquote(base_href),
|
||||
]
|
||||
base_name = os.path.basename(base_href).lower()
|
||||
for k in list(doc_order.keys()) + list(doc_order_decoded.keys()):
|
||||
if os.path.basename(k).lower() == base_name:
|
||||
candidates.append(k)
|
||||
for candidate in candidates:
|
||||
if candidate in doc_order:
|
||||
return candidate, doc_order[candidate]
|
||||
elif candidate in doc_order_decoded:
|
||||
return candidate, doc_order_decoded[candidate]
|
||||
return None, None
|
||||
|
||||
def _parse_ncx_navpoint(
|
||||
self,
|
||||
nav_point,
|
||||
@@ -603,27 +641,15 @@ class HandlerDialog(QDialog):
|
||||
|
||||
if src:
|
||||
base_href, fragment = src.split("#", 1) if "#" in src else (src, None)
|
||||
# Try both original and decoded hrefs
|
||||
doc_key = None
|
||||
if base_href in doc_order:
|
||||
doc_key = base_href
|
||||
doc_idx = doc_order[base_href]
|
||||
elif urllib.parse.unquote(base_href) in doc_order:
|
||||
doc_key = urllib.parse.unquote(base_href)
|
||||
doc_idx = doc_order[doc_key]
|
||||
elif base_href in doc_order_decoded:
|
||||
doc_key = base_href
|
||||
doc_idx = doc_order_decoded[base_href]
|
||||
elif urllib.parse.unquote(base_href) in doc_order_decoded:
|
||||
doc_key = urllib.parse.unquote(base_href)
|
||||
doc_idx = doc_order_decoded[doc_key]
|
||||
else:
|
||||
doc_key, doc_idx = self._find_doc_key(
|
||||
base_href, doc_order, doc_order_decoded
|
||||
)
|
||||
if not doc_key:
|
||||
logging.warning(
|
||||
f"Navigation entry '{title}' points to '{base_href}', which is not in the spine or document list."
|
||||
f"Navigation entry '{title}' points to '{base_href}', which is not in the spine or document list (even after basename fallback)."
|
||||
)
|
||||
current_entry_node["has_content"] = False
|
||||
doc_key = None
|
||||
if doc_key is not None:
|
||||
else:
|
||||
position = find_position_func(doc_key, fragment)
|
||||
entry_data = {
|
||||
"src": src,
|
||||
@@ -698,28 +724,15 @@ class HandlerDialog(QDialog):
|
||||
current_entry_node["title"] = title
|
||||
current_entry_node["src"] = src
|
||||
|
||||
doc_key = None
|
||||
doc_idx = None
|
||||
position = 0
|
||||
fragment = None
|
||||
if src:
|
||||
base_href, fragment = src.split("#", 1) if "#" in src else (src, None)
|
||||
# Try both original and decoded hrefs
|
||||
doc_key = None
|
||||
if base_href in doc_order:
|
||||
doc_key = base_href
|
||||
doc_idx = doc_order[base_href]
|
||||
elif urllib.parse.unquote(base_href) in doc_order:
|
||||
doc_key = urllib.parse.unquote(base_href)
|
||||
doc_idx = doc_order[doc_key]
|
||||
elif base_href in doc_order_decoded:
|
||||
doc_key = base_href
|
||||
doc_idx = doc_order_decoded[base_href]
|
||||
elif urllib.parse.unquote(base_href) in doc_order_decoded:
|
||||
doc_key = urllib.parse.unquote(base_href)
|
||||
doc_idx = doc_order_decoded[doc_key]
|
||||
else:
|
||||
logging.warning(
|
||||
f"Navigation entry '{title}' points to '{base_href}', which is not in the spine or document list."
|
||||
doc_key, doc_idx = self._find_doc_key(
|
||||
base_href, doc_order, doc_order_decoded
|
||||
)
|
||||
current_entry_node["has_content"] = False
|
||||
doc_key = None
|
||||
if doc_key is not None:
|
||||
position = find_position_func(doc_key, fragment)
|
||||
entry_data = {
|
||||
@@ -731,13 +744,16 @@ class HandlerDialog(QDialog):
|
||||
}
|
||||
ordered_entries.append(entry_data)
|
||||
current_entry_node["has_content"] = True
|
||||
else:
|
||||
logging.warning(
|
||||
f"Navigation entry '{title}' points to '{base_href}', which is not in the spine or document list (even after basename fallback)."
|
||||
)
|
||||
current_entry_node["has_content"] = False
|
||||
else:
|
||||
current_entry_node["has_content"] = False
|
||||
|
||||
child_ol = li_element.find("ol", recursive=False)
|
||||
if child_ol:
|
||||
for child_ol in li_element.find_all("ol", recursive=False):
|
||||
for child_li in child_ol.find_all("li", recursive=False):
|
||||
# Pass find_position_func down recursively
|
||||
self._parse_html_nav_li(
|
||||
child_li,
|
||||
ordered_entries,
|
||||
@@ -746,11 +762,6 @@ class HandlerDialog(QDialog):
|
||||
current_entry_node["children"],
|
||||
find_position_func,
|
||||
)
|
||||
|
||||
if title and (
|
||||
current_entry_node.get("has_content", False)
|
||||
or current_entry_node["children"]
|
||||
):
|
||||
tree_structure_list.append(current_entry_node)
|
||||
|
||||
def _find_position_robust(self, doc_href, fragment_id):
|
||||
|
||||
@@ -31,6 +31,28 @@ LANGUAGE_DESCRIPTIONS = {
|
||||
"z": "Mandarin Chinese",
|
||||
}
|
||||
|
||||
# Supported sound formats
|
||||
SUPPORTED_SOUND_FORMATS = [
|
||||
"wav",
|
||||
"mp3",
|
||||
"opus",
|
||||
"m4b",
|
||||
"flac",
|
||||
]
|
||||
|
||||
# Supported subtitle formats
|
||||
SUPPORTED_SUBTITLE_FORMATS = [
|
||||
"srt",
|
||||
"ass",
|
||||
]
|
||||
|
||||
# Supported input formats
|
||||
SUPPORTED_INPUT_FORMATS = [
|
||||
"epub",
|
||||
"pdf",
|
||||
"txt",
|
||||
]
|
||||
|
||||
# Supported languages for subtitle generation
|
||||
# Currently, only 'a (American English)' and 'b (British English)' are supported for subtitle generation.
|
||||
# This is because tokens that contain timestamps are not generated for other languages in the Kokoro pipeline.
|
||||
|
||||
+161
-56
@@ -1,6 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
import chardet
|
||||
import charset_normalizer
|
||||
@@ -9,20 +8,22 @@ from platformdirs import user_desktop_dir
|
||||
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer
|
||||
from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
|
||||
import soundfile as sf
|
||||
from abogen.utils import clean_text, create_process
|
||||
from abogen.utils import clean_text, create_process, get_user_cache_path
|
||||
from abogen.constants import (
|
||||
PROGRAM_NAME,
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
SAMPLE_VOICE_TEXTS,
|
||||
COLORS,
|
||||
CHAPTER_OPTIONS_COUNTDOWN,
|
||||
SUBTITLE_FORMATS,
|
||||
SUPPORTED_SOUND_FORMATS,
|
||||
SUPPORTED_SUBTITLE_FORMATS,
|
||||
)
|
||||
from abogen.voice_formulas import get_new_voice
|
||||
import abogen.hf_tracker as hf_tracker
|
||||
import static_ffmpeg
|
||||
import threading # for efficient waiting
|
||||
import subprocess
|
||||
import platform
|
||||
|
||||
|
||||
def get_sample_voice_text(lang_code):
|
||||
@@ -310,8 +311,15 @@ class ConversionThread(QThread):
|
||||
|
||||
self.log_updated.emit("\nInitializing TTS pipeline...")
|
||||
|
||||
# Set device based on use_gpu setting
|
||||
device = "cuda" if self.use_gpu else "cpu"
|
||||
# Set device based on use_gpu setting and platform
|
||||
if self.use_gpu:
|
||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||
device = "mps" # Use MPS for Apple Silicon
|
||||
else:
|
||||
device = "cuda" # Use CUDA for other platforms
|
||||
else:
|
||||
device = "cpu"
|
||||
|
||||
tts = self.KPipeline(
|
||||
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
|
||||
)
|
||||
@@ -426,29 +434,19 @@ class ConversionThread(QThread):
|
||||
)
|
||||
# Find a unique suffix for both folder and merged file, always
|
||||
counter = 1
|
||||
allowed_exts = set(SUPPORTED_SOUND_FORMATS + SUPPORTED_SUBTITLE_FORMATS)
|
||||
while True:
|
||||
suffix = f"_{counter}" if counter > 1 else ""
|
||||
chapters_out_dir_candidate = os.path.join(
|
||||
parent_dir, f"{base_name}{suffix}_chapters"
|
||||
)
|
||||
merged_file_candidate = os.path.join(
|
||||
parent_dir, f"{base_name}{suffix}.{self.output_format}"
|
||||
# Only check for files with allowed extensions (extension without dot, case-insensitive)
|
||||
clash = any(
|
||||
os.path.splitext(fname)[0] == f"{base_name}{suffix}"
|
||||
and os.path.splitext(fname)[1][1:].lower() in allowed_exts
|
||||
for fname in os.listdir(parent_dir)
|
||||
)
|
||||
merged_srt_candidate = (
|
||||
os.path.splitext(merged_file_candidate)[0] + ".srt"
|
||||
)
|
||||
if (
|
||||
not os.path.exists(chapters_out_dir_candidate)
|
||||
and (
|
||||
not merge_chapters_at_end
|
||||
or not os.path.exists(merged_file_candidate)
|
||||
)
|
||||
and (
|
||||
self.subtitle_mode == "Disabled"
|
||||
or not merge_chapters_at_end
|
||||
or not os.path.exists(merged_srt_candidate)
|
||||
)
|
||||
):
|
||||
if not os.path.exists(chapters_out_dir_candidate) and not clash:
|
||||
break
|
||||
counter += 1
|
||||
if save_chapters_separately and total_chapters > 1:
|
||||
@@ -477,6 +475,8 @@ class ConversionThread(QThread):
|
||||
{"chapter": chapter[0], "start": 0.0, "end": 0.0}
|
||||
for chapter in chapters
|
||||
]
|
||||
# SRT numbering fix: use a global counter
|
||||
merged_srt_index = 1 # SRT numbering for merged file
|
||||
# Prepare output file/ffmpeg process for merged output
|
||||
if self.output_format in ["wav", "mp3", "flac"]:
|
||||
merged_out_file = sf.SoundFile(
|
||||
@@ -492,19 +492,29 @@ class ConversionThread(QThread):
|
||||
static_ffmpeg.add_paths()
|
||||
merged_out_file = None
|
||||
ffmpeg_proc = None
|
||||
metadata_options = (
|
||||
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
|
||||
)
|
||||
# Prepare ffmpeg command for m4b output
|
||||
metadata_options = self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-thread_queue_size", "32768",
|
||||
"-f", "f32le",
|
||||
"-ar", "24000",
|
||||
"-ac", "1",
|
||||
"-i", "pipe:0",
|
||||
"-c:a", "aac",
|
||||
"-q:a", "2",
|
||||
"-movflags", "+faststart+use_metadata_tags",
|
||||
"-thread_queue_size",
|
||||
"32768",
|
||||
"-f",
|
||||
"f32le",
|
||||
"-ar",
|
||||
"24000",
|
||||
"-ac",
|
||||
"1",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-q:a",
|
||||
"2",
|
||||
"-movflags",
|
||||
"+faststart+use_metadata_tags",
|
||||
]
|
||||
cmd += metadata_options
|
||||
cmd.append(merged_out_path)
|
||||
@@ -556,6 +566,11 @@ class ConversionThread(QThread):
|
||||
merged_subtitle_file.write("[Script Info]\n")
|
||||
merged_subtitle_file.write("Title: Generated by Abogen\n")
|
||||
merged_subtitle_file.write("ScriptType: v4.00+\n\n")
|
||||
# Add style definitions for karaoke highlighting
|
||||
if self.subtitle_mode == "Sentence + Highlighting":
|
||||
merged_subtitle_file.write("[V4+ Styles]\n")
|
||||
merged_subtitle_file.write("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n")
|
||||
merged_subtitle_file.write("Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n")
|
||||
merged_subtitle_file.write("[Events]\n")
|
||||
merged_subtitle_file.write(
|
||||
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
|
||||
@@ -599,6 +614,7 @@ class ConversionThread(QThread):
|
||||
{"chapter": chapter[0], "start": 0.0, "end": 0.0}
|
||||
for chapter in chapters
|
||||
]
|
||||
srt_index = 1 # SRT numbering fix for chapter-only mode
|
||||
# Instead of processing the whole text, process by chapter
|
||||
for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1):
|
||||
chapter_out_path = None
|
||||
@@ -677,6 +693,9 @@ class ConversionThread(QThread):
|
||||
continue
|
||||
# Open chapter subtitle file for incremental writing if needed
|
||||
chapter_subtitle_file = None
|
||||
chapter_srt_index = (
|
||||
1 # Initialize SRT numbering for this chapter file
|
||||
)
|
||||
if self.subtitle_mode != "Disabled":
|
||||
subtitle_format = getattr(self, "subtitle_format", "srt")
|
||||
file_extension = "ass" if "ass" in subtitle_format else "srt"
|
||||
@@ -694,6 +713,13 @@ class ConversionThread(QThread):
|
||||
chapter_subtitle_file.write("[Script Info]\n")
|
||||
chapter_subtitle_file.write("Title: Generated by Abogen\n")
|
||||
chapter_subtitle_file.write("ScriptType: v4.00+\n\n")
|
||||
|
||||
# Add style definitions for karaoke highlighting
|
||||
if self.subtitle_mode == "Sentence + Highlighting":
|
||||
chapter_subtitle_file.write("[V4+ Styles]\n")
|
||||
chapter_subtitle_file.write("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n")
|
||||
chapter_subtitle_file.write("Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n")
|
||||
|
||||
chapter_subtitle_file.write("[Events]\n")
|
||||
chapter_subtitle_file.write(
|
||||
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
|
||||
@@ -807,7 +833,7 @@ class ConversionThread(QThread):
|
||||
tokens_with_timestamps,
|
||||
new_entries,
|
||||
self.max_subtitle_words,
|
||||
fallback_end_time=chunk_start + chunk_dur
|
||||
fallback_end_time=chunk_start + chunk_dur,
|
||||
)
|
||||
if merged_subtitle_file:
|
||||
subtitle_format = getattr(
|
||||
@@ -817,16 +843,18 @@ class ConversionThread(QThread):
|
||||
for start, end, text in new_entries:
|
||||
start_time = self._ass_time(start)
|
||||
end_time = self._ass_time(end)
|
||||
# Use karaoke effect for highlighting mode
|
||||
effect = "karaoke" if self.subtitle_mode == "Sentence + Highlighting" else ""
|
||||
merged_subtitle_file.write(
|
||||
f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,,{merged_subtitle_alignment_tag}{text}\n"
|
||||
f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,{effect},{merged_subtitle_alignment_tag}{text}\n"
|
||||
)
|
||||
else:
|
||||
for i, (start, end, text) in enumerate(
|
||||
new_entries, 1
|
||||
):
|
||||
for entry in new_entries:
|
||||
start, end, text = entry
|
||||
merged_subtitle_file.write(
|
||||
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
||||
f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
||||
)
|
||||
merged_srt_index += 1
|
||||
# Per-chapter subtitle processing for both file and ffmpeg_proc
|
||||
if chapter_out_file or chapter_ffmpeg_proc:
|
||||
new_chapter_entries = []
|
||||
@@ -844,16 +872,18 @@ class ConversionThread(QThread):
|
||||
for start, end, text in new_chapter_entries:
|
||||
start_time = self._ass_time(start)
|
||||
end_time = self._ass_time(end)
|
||||
# Use karaoke effect for highlighting mode
|
||||
effect = "karaoke" if self.subtitle_mode == "Sentence + Highlighting" else ""
|
||||
chapter_subtitle_file.write(
|
||||
f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,,{chapter_subtitle_alignment_tag}{text}\n"
|
||||
f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,{effect},{chapter_subtitle_alignment_tag}{text}\n"
|
||||
)
|
||||
else:
|
||||
for i, (start, end, text) in enumerate(
|
||||
new_chapter_entries, 1
|
||||
):
|
||||
for entry in new_chapter_entries:
|
||||
start, end, text = entry
|
||||
chapter_subtitle_file.write(
|
||||
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
||||
f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
||||
)
|
||||
chapter_srt_index += 1
|
||||
if merge_chapters_at_end:
|
||||
current_time += chunk_dur
|
||||
if chapter_out_file or chapter_ffmpeg_proc:
|
||||
@@ -945,17 +975,27 @@ class ConversionThread(QThread):
|
||||
orig_path = merged_out_path
|
||||
root, ext = os.path.splitext(orig_path)
|
||||
tmp_path = root + ".tmp" + ext
|
||||
metadata_options = (
|
||||
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
|
||||
)
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i", orig_path,
|
||||
"-i", chapters_info_path,
|
||||
"-map", "0:a",
|
||||
"-map_metadata", "1",
|
||||
"-map_chapters", "1",
|
||||
"-c:a", "copy",
|
||||
tmp_path
|
||||
"-i",
|
||||
orig_path,
|
||||
"-i",
|
||||
chapters_info_path,
|
||||
"-map",
|
||||
"0:a",
|
||||
"-map_metadata",
|
||||
"1",
|
||||
"-map_chapters",
|
||||
"1",
|
||||
"-c:a",
|
||||
"copy",
|
||||
]
|
||||
cmd += metadata_options
|
||||
cmd.append(tmp_path)
|
||||
proc = create_process(cmd)
|
||||
proc.wait()
|
||||
os.replace(tmp_path, orig_path)
|
||||
@@ -1126,13 +1166,71 @@ class ConversionThread(QThread):
|
||||
return f"{h}:{m:02}:{s:02}.{cs:02}"
|
||||
|
||||
def _process_subtitle_tokens(
|
||||
self, tokens_with_timestamps, subtitle_entries, max_subtitle_words, fallback_end_time=None
|
||||
self,
|
||||
tokens_with_timestamps,
|
||||
subtitle_entries,
|
||||
max_subtitle_words,
|
||||
fallback_end_time=None,
|
||||
):
|
||||
"""Helper function to process subtitle tokens according to the subtitle mode"""
|
||||
if not tokens_with_timestamps:
|
||||
return
|
||||
|
||||
if self.subtitle_mode == "Sentence" or self.subtitle_mode == "Sentence + Comma":
|
||||
if self.subtitle_mode == "Sentence + Highlighting":
|
||||
# Sentence-based processing with karaoke highlighting
|
||||
separator = r"[.!?]"
|
||||
current_sentence = []
|
||||
word_count = 0
|
||||
|
||||
for token in tokens_with_timestamps:
|
||||
current_sentence.append(token)
|
||||
word_count += 1
|
||||
|
||||
# Split sentences based on separator or word count
|
||||
if (
|
||||
re.search(separator, token["text"]) and token["whitespace"] == " "
|
||||
) or word_count >= max_subtitle_words:
|
||||
if current_sentence:
|
||||
# Create karaoke subtitle entry for this sentence
|
||||
start_time = current_sentence[0]["start"]
|
||||
end_time = current_sentence[-1]["end"]
|
||||
|
||||
# Generate karaoke text with background highlighting
|
||||
karaoke_text = ""
|
||||
for t in current_sentence:
|
||||
# Calculate duration in centiseconds
|
||||
duration = t["end"] - t["start"] if t["end"] and t["start"] else 0.5
|
||||
duration_cs = int(duration * 100)
|
||||
# Add karaoke effect - relies on style's SecondaryColour for highlighting
|
||||
karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}"
|
||||
|
||||
subtitle_entries.append(
|
||||
(start_time, end_time, karaoke_text.strip())
|
||||
)
|
||||
current_sentence = []
|
||||
word_count = 0
|
||||
|
||||
# Add any remaining tokens as a sentence
|
||||
if current_sentence:
|
||||
start_time = current_sentence[0]["start"]
|
||||
end_time = current_sentence[-1]["end"]
|
||||
|
||||
# Generate karaoke text for remaining tokens
|
||||
karaoke_text = ""
|
||||
for t in current_sentence:
|
||||
duration = t["end"] - t["start"] if t["end"] and t["start"] else 0.5
|
||||
duration_cs = int(duration * 100)
|
||||
karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}"
|
||||
subtitle_entries.append((start_time, end_time, karaoke_text.strip()))
|
||||
|
||||
# Fallback for last entry
|
||||
if subtitle_entries and fallback_end_time is not None:
|
||||
last_entry = subtitle_entries[-1]
|
||||
start, end, text = last_entry
|
||||
if end is None or end <= start or end <= 0:
|
||||
subtitle_entries[-1] = (start, fallback_end_time, text)
|
||||
|
||||
elif self.subtitle_mode == "Sentence" or self.subtitle_mode == "Sentence + Comma":
|
||||
# Define separator pattern based on mode
|
||||
separator = r"[.!?]" if self.subtitle_mode == "Sentence" else r"[.!?,]"
|
||||
current_sentence = []
|
||||
@@ -1222,6 +1320,7 @@ class ConversionThread(QThread):
|
||||
start, end, text = last_entry
|
||||
if end is None or end <= start or end <= 0:
|
||||
subtitle_entries[-1] = (start, fallback_end_time, text)
|
||||
|
||||
def cancel(self):
|
||||
self.cancel_requested = True
|
||||
self.should_cancel = True
|
||||
@@ -1272,10 +1371,7 @@ class VoicePreviewThread(QThread):
|
||||
self.use_gpu = use_gpu
|
||||
|
||||
# Cache location for preview audio
|
||||
self.cache_dir = os.path.join(
|
||||
tempfile.gettempdir(), PROGRAM_NAME, "preview_cache"
|
||||
)
|
||||
os.makedirs(self.cache_dir, exist_ok=True)
|
||||
self.cache_dir = get_user_cache_path("preview_cache")
|
||||
|
||||
# Calculate cache path
|
||||
self.cache_path = self._get_cache_path()
|
||||
@@ -1301,7 +1397,16 @@ class VoicePreviewThread(QThread):
|
||||
|
||||
# Generate the preview and save to cache
|
||||
try:
|
||||
device = "cuda" if self.use_gpu else "cpu"
|
||||
|
||||
# Set device based on use_gpu setting and platform
|
||||
if self.use_gpu:
|
||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||
device = "mps" # Use MPS for Apple Silicon
|
||||
else:
|
||||
device = "cuda" # Use CUDA for other platforms
|
||||
else:
|
||||
device = "cpu"
|
||||
|
||||
tts = self.kpipeline_class(
|
||||
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
|
||||
)
|
||||
|
||||
+106
-62
@@ -62,6 +62,7 @@ from abogen.utils import (
|
||||
prevent_sleep_end,
|
||||
calculate_text_length,
|
||||
get_resource_path,
|
||||
get_user_cache_path,
|
||||
LoadPipelineThread,
|
||||
)
|
||||
from abogen.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread
|
||||
@@ -564,12 +565,12 @@ class TextboxDialog(QDialog):
|
||||
self.reject()
|
||||
else:
|
||||
# Check if we need to warn about overwriting a non-temporary file
|
||||
if hasattr(self, "is_non_temp_file") and self.is_non_temp_file:
|
||||
if hasattr(self, "is_non_cache_file") and self.is_non_cache_file:
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(QMessageBox.Warning)
|
||||
msg_box.setWindowTitle("File Overwrite Warning")
|
||||
msg_box.setText(
|
||||
f"You are about to overwrite the original file:\n{self.non_temp_file_path}"
|
||||
f"You are about to overwrite the original file:\n{self.non_cache_file_path}"
|
||||
)
|
||||
msg_box.setInformativeText("Do you want to continue?")
|
||||
msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
|
||||
@@ -591,11 +592,11 @@ class TextboxDialog(QDialog):
|
||||
|
||||
# Get default filename from original file if editing
|
||||
initial_path = ""
|
||||
if hasattr(self, "non_temp_file_path") and self.non_temp_file_path:
|
||||
initial_path = self.non_temp_file_path
|
||||
if hasattr(self, "non_cache_file_path") and self.non_cache_file_path:
|
||||
initial_path = self.non_cache_file_path
|
||||
|
||||
# For EPUB and PDF files, use the displayed_file_path from the main window
|
||||
# This gives a better filename instead of the temporary file path
|
||||
# This gives a better filename instead of the cache file path
|
||||
main_window = self.parent()
|
||||
if (
|
||||
hasattr(main_window, "displayed_file_path")
|
||||
@@ -894,6 +895,7 @@ class abogen(QWidget):
|
||||
"Disabled: No subtitles will be generated.\n"
|
||||
"Sentence: Subtitles will be generated for each sentence.\n"
|
||||
"Sentence + Comma: Subtitles will be generated for each sentence and comma.\n"
|
||||
"Sentence + Highlighting: Subtitles with word-by-word karaoke highlighting.\n"
|
||||
"1+ word: Subtitles will be generated for each word(s).\n\n"
|
||||
"Supported languages for subtitle generation:\n"
|
||||
+ "\n".join(
|
||||
@@ -901,7 +903,7 @@ class abogen(QWidget):
|
||||
for lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
||||
)
|
||||
)
|
||||
subtitle_options = ["Disabled", "Sentence", "Sentence + Comma"] + [
|
||||
subtitle_options = ["Disabled", "Sentence", "Sentence + Comma", "Sentence + Highlighting"] + [
|
||||
f"{i} word" if i == 1 else f"{i} words" for i in range(1, 11)
|
||||
]
|
||||
self.subtitle_combo.addItems(subtitle_options)
|
||||
@@ -969,6 +971,25 @@ class abogen(QWidget):
|
||||
lambda i: self.set_subtitle_format(self.subtitle_format_combo.itemData(i))
|
||||
)
|
||||
subtitle_format_layout.addWidget(self.subtitle_format_combo)
|
||||
# If subtitle mode requires highlighting, SRT is not supported. Disable SRT item
|
||||
# and auto-switch to a compatible ASS format if SRT is currently selected.
|
||||
try:
|
||||
if hasattr(self, "subtitle_mode") and self.subtitle_mode == "Sentence + Highlighting":
|
||||
idx_srt = self.subtitle_format_combo.findData("srt")
|
||||
if idx_srt >= 0:
|
||||
item = self.subtitle_format_combo.model().item(idx_srt)
|
||||
if item is not None:
|
||||
item.setEnabled(False)
|
||||
# If current selection is SRT, switch to centered narrow ASS
|
||||
if self.subtitle_format_combo.currentData() == "srt":
|
||||
new_idx = self.subtitle_format_combo.findData("ass_centered_narrow")
|
||||
if new_idx >= 0:
|
||||
self.subtitle_format_combo.setCurrentIndex(new_idx)
|
||||
# Persist the change
|
||||
self.set_subtitle_format(self.subtitle_format_combo.itemData(new_idx))
|
||||
except Exception:
|
||||
# Fail-safe: don't crash UI if model manipulation isn't supported on some platforms
|
||||
pass
|
||||
controls_layout.addLayout(subtitle_format_layout)
|
||||
|
||||
# Replace single newlines dropdown (acts like checkbox)
|
||||
@@ -1195,7 +1216,7 @@ class abogen(QWidget):
|
||||
if book_path.lower().endswith(".pdf"):
|
||||
self.pdf_has_bookmarks = getattr(dialog, "has_pdf_bookmarks", False)
|
||||
|
||||
# Use "abogen" prefix for temporary files
|
||||
# Use "abogen" prefix for cache files
|
||||
# Extract base name without extension
|
||||
base_name = os.path.splitext(os.path.basename(book_path))[0]
|
||||
|
||||
@@ -1205,15 +1226,15 @@ class abogen(QWidget):
|
||||
self, "Select Project Folder", "", QFileDialog.ShowDirsOnly
|
||||
)
|
||||
if not project_dir:
|
||||
# User cancelled, fallback to temp
|
||||
# User cancelled, fallback to cache
|
||||
self.save_as_project = False
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME)
|
||||
cache_dir = get_user_cache_path()
|
||||
else:
|
||||
# Create project folder structure
|
||||
project_name = f"{base_name}_project"
|
||||
project_dir = os.path.join(project_dir, project_name)
|
||||
temp_dir = os.path.join(project_dir, "text")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
cache_dir = os.path.join(project_dir, "text")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
|
||||
# Save metadata if available
|
||||
meta_dir = os.path.join(project_dir, "metadata")
|
||||
@@ -1268,11 +1289,10 @@ class abogen(QWidget):
|
||||
with open(cover_path, "wb") as f:
|
||||
f.write(dialog.book_metadata["cover_image"])
|
||||
else:
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME)
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
cache_dir = get_user_cache_path()
|
||||
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
prefix=f"{base_name}_", suffix=".txt", dir=temp_dir
|
||||
prefix=f"{base_name}_", suffix=".txt", dir=cache_dir
|
||||
)
|
||||
os.close(fd)
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
@@ -1293,13 +1313,13 @@ class abogen(QWidget):
|
||||
return
|
||||
|
||||
editing = False
|
||||
is_temp_file = False
|
||||
is_cache_file = False
|
||||
# If path is explicitly provided, use it
|
||||
if file_path and os.path.exists(file_path):
|
||||
editing = True
|
||||
edit_file = file_path
|
||||
# Check if this is a temporary file
|
||||
is_temp_file = tempfile.gettempdir() in file_path
|
||||
# Check if this is a cache file
|
||||
is_cache_file = get_user_cache_path() in file_path
|
||||
# Otherwise use selected_file if it's a txt file
|
||||
elif (
|
||||
self.selected_file_type == "txt"
|
||||
@@ -1308,8 +1328,8 @@ class abogen(QWidget):
|
||||
):
|
||||
editing = True
|
||||
edit_file = self.selected_file
|
||||
# Check if this is a temporary file
|
||||
is_temp_file = tempfile.gettempdir() in self.selected_file
|
||||
# Check if this is a cache file
|
||||
is_cache_file = get_user_cache_path() in self.selected_file
|
||||
|
||||
dialog = TextboxDialog(self)
|
||||
if editing:
|
||||
@@ -1321,10 +1341,10 @@ class abogen(QWidget):
|
||||
dialog.text_edit.toPlainText()
|
||||
) # Store original text
|
||||
|
||||
# If editing a non-temporary file, alert the user
|
||||
if not is_temp_file:
|
||||
dialog.is_non_temp_file = True
|
||||
dialog.non_temp_file_path = edit_file
|
||||
# If editing a non-cache file, alert the user
|
||||
if not is_cache_file:
|
||||
dialog.is_non_cache_file = True
|
||||
dialog.non_cache_file_path = edit_file
|
||||
except Exception:
|
||||
pass
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
@@ -1342,10 +1362,9 @@ class abogen(QWidget):
|
||||
# Hide chapters button since we're using custom text now
|
||||
self.input_box.chapters_btn.hide()
|
||||
else:
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME)
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
cache_dir = get_user_cache_path()
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
prefix="abogen_", suffix=".txt", dir=temp_dir
|
||||
prefix="abogen_", suffix=".txt", dir=cache_dir
|
||||
)
|
||||
os.close(fd)
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
@@ -2153,7 +2172,7 @@ class abogen(QWidget):
|
||||
if not lang_to_cache or not voice_to_cache: # Not enough info
|
||||
return None
|
||||
|
||||
cache_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME, "preview_cache")
|
||||
cache_dir = get_user_cache_path("preview_cache")
|
||||
|
||||
if "*" in voice_to_cache: # Voice formula
|
||||
voice_id = (
|
||||
@@ -2472,6 +2491,30 @@ class abogen(QWidget):
|
||||
self.subtitle_format_combo.setEnabled(False)
|
||||
else:
|
||||
self.subtitle_format_combo.setEnabled(True)
|
||||
# If highlighting mode selected, SRT is not supported. Disable SRT option and
|
||||
# switch away from it if currently selected.
|
||||
try:
|
||||
idx_srt = self.subtitle_format_combo.findData("srt")
|
||||
if mode == "Sentence + Highlighting":
|
||||
if idx_srt >= 0:
|
||||
item = self.subtitle_format_combo.model().item(idx_srt)
|
||||
if item is not None:
|
||||
item.setEnabled(False)
|
||||
# If current format is SRT, switch to a compatible ASS format
|
||||
if self.subtitle_format_combo.currentData() == "srt":
|
||||
new_idx = self.subtitle_format_combo.findData("ass_centered_narrow")
|
||||
if new_idx >= 0:
|
||||
self.subtitle_format_combo.setCurrentIndex(new_idx)
|
||||
self.set_subtitle_format(self.subtitle_format_combo.itemData(new_idx))
|
||||
else:
|
||||
# Re-enable SRT option when not in highlighting mode
|
||||
if idx_srt >= 0:
|
||||
item = self.subtitle_format_combo.model().item(idx_srt)
|
||||
if item is not None:
|
||||
item.setEnabled(True)
|
||||
except Exception:
|
||||
# Ignore errors interacting with model (defensive)
|
||||
pass
|
||||
|
||||
def on_format_changed(self, fmt):
|
||||
self.selected_format = fmt
|
||||
@@ -2771,17 +2814,17 @@ class abogen(QWidget):
|
||||
reveal_config_action.triggered.connect(self.reveal_config_in_explorer)
|
||||
menu.addAction(reveal_config_action)
|
||||
|
||||
# Add open temp directory option
|
||||
open_temp_action = QAction("Open temp directory", self)
|
||||
open_temp_action.triggered.connect(self.open_temp_directory)
|
||||
menu.addAction(open_temp_action)
|
||||
# Add open cache directory option
|
||||
open_cache_action = QAction("Open cache directory", self)
|
||||
open_cache_action.triggered.connect(self.open_cache_directory)
|
||||
menu.addAction(open_cache_action)
|
||||
|
||||
# Add clear temporary files option
|
||||
clear_temp_action = QAction("Clear temporary files", self)
|
||||
clear_temp_action.triggered.connect(self.clear_temp_files)
|
||||
menu.addAction(clear_temp_action)
|
||||
# Add clear cache files option
|
||||
clear_cache_action = QAction("Clear cache files", self)
|
||||
clear_cache_action.triggered.connect(self.clear_cache_files)
|
||||
menu.addAction(clear_cache_action)
|
||||
|
||||
# Add seperator"
|
||||
# Add separator
|
||||
menu.addSeparator()
|
||||
|
||||
# Add "Disable Kokoro's internet access" option
|
||||
@@ -2822,14 +2865,15 @@ class abogen(QWidget):
|
||||
def restart_app(self):
|
||||
from PyQt5.QtCore import QProcess
|
||||
import sys
|
||||
|
||||
exe = sys.executable
|
||||
args = sys.argv
|
||||
|
||||
# On Windows, use .exe if available
|
||||
if platform.system() == "Windows":
|
||||
script_path = args[0]
|
||||
if not script_path.lower().endswith('.exe'):
|
||||
exe_path = os.path.splitext(script_path)[0] + '.exe'
|
||||
if not script_path.lower().endswith(".exe"):
|
||||
exe_path = os.path.splitext(script_path)[0] + ".exe"
|
||||
if os.path.exists(exe_path):
|
||||
args[0] = exe_path
|
||||
|
||||
@@ -2899,21 +2943,21 @@ class abogen(QWidget):
|
||||
self, "Config Error", f"Could not open config location:\n{e}"
|
||||
)
|
||||
|
||||
def open_temp_directory(self):
|
||||
"""Open the temporary directory used by the program."""
|
||||
def open_cache_directory(self):
|
||||
"""Open the cache directory used by the program."""
|
||||
try:
|
||||
# Get the temp directory path
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME)
|
||||
# Get the cache directory path
|
||||
cache_dir = get_user_cache_path()
|
||||
|
||||
# Create the directory if it doesn't exist
|
||||
if not os.path.exists(temp_dir):
|
||||
os.makedirs(temp_dir)
|
||||
if not os.path.exists(cache_dir):
|
||||
os.makedirs(cache_dir)
|
||||
|
||||
# Open the directory in file explorer
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(temp_dir))
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(cache_dir))
|
||||
except Exception as e:
|
||||
QMessageBox.critical(
|
||||
self, "Temp Directory Error", f"Could not open temp directory:\n{e}"
|
||||
self, "Cache Directory Error", f"Could not open cache directory:\n{e}"
|
||||
)
|
||||
|
||||
def add_shortcut_to_desktop(self):
|
||||
@@ -3253,23 +3297,23 @@ Categories=AudioVideo;Audio;Utility;
|
||||
)
|
||||
pass
|
||||
|
||||
def clear_temp_files(self):
|
||||
"""Clear temporary files created by the program."""
|
||||
def clear_cache_files(self):
|
||||
"""Clear cache files created by the program."""
|
||||
import glob
|
||||
|
||||
try:
|
||||
# Get the abogen temp directory
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME)
|
||||
# Get the abogen cache directory
|
||||
cache_dir = get_user_cache_path()
|
||||
|
||||
# Find all .txt files in the abogen temp directory
|
||||
pattern = os.path.join(temp_dir, "*.txt")
|
||||
temp_files = glob.glob(pattern)
|
||||
# Find all .txt files in the abogen cache directory
|
||||
pattern = os.path.join(cache_dir, "*.txt")
|
||||
cache_files = glob.glob(pattern)
|
||||
|
||||
# Count the files
|
||||
file_count = len(temp_files)
|
||||
file_count = len(cache_files)
|
||||
|
||||
# Check for preview cache files
|
||||
preview_cache_dir = os.path.join(temp_dir, "preview_cache")
|
||||
preview_cache_dir = os.path.join(cache_dir, "preview_cache")
|
||||
preview_files = []
|
||||
if os.path.exists(preview_cache_dir):
|
||||
preview_pattern = os.path.join(preview_cache_dir, "*.wav")
|
||||
@@ -3279,16 +3323,16 @@ Categories=AudioVideo;Audio;Utility;
|
||||
|
||||
if file_count == 0 and preview_count == 0:
|
||||
QMessageBox.information(
|
||||
self, "No Temporary Files", "No temporary files were found."
|
||||
self, "No Cache Files", "No cache files were found."
|
||||
)
|
||||
return
|
||||
|
||||
# Create a custom message box with checkbox
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(QMessageBox.Question)
|
||||
msg_box.setWindowTitle("Clear Temporary Files")
|
||||
msg_box.setWindowTitle("Clear Cache Files")
|
||||
|
||||
msg_text = f"Found {file_count} temporary file{'s' if file_count != 1 else ''} in the {PROGRAM_NAME} temp folder."
|
||||
msg_text = f"Found {file_count} cache file{'s' if file_count != 1 else ''} in the {PROGRAM_NAME} cache folder."
|
||||
if preview_count > 0:
|
||||
msg_text += f"\nAlso found {preview_count} preview cache file{'s' if preview_count != 1 else ''}."
|
||||
|
||||
@@ -3312,7 +3356,7 @@ Categories=AudioVideo;Audio;Utility;
|
||||
|
||||
# Delete the text files
|
||||
deleted_count = 0
|
||||
for file_path in temp_files:
|
||||
for file_path in cache_files:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
deleted_count += 1
|
||||
@@ -3335,12 +3379,12 @@ Categories=AudioVideo;Audio;Utility;
|
||||
result_msg += f"\nAlso deleted {deleted_preview_count} preview cache file{'s' if deleted_preview_count != 1 else ''}."
|
||||
|
||||
# Show results
|
||||
QMessageBox.information(self, "Temporary Files Cleared", result_msg)
|
||||
QMessageBox.information(self, "Cache Files Cleared", result_msg)
|
||||
|
||||
# If currently selected file is in the temp directory, clear the UI
|
||||
# If currently selected file is in the cache directory, clear the UI
|
||||
if (
|
||||
self.selected_file
|
||||
and os.path.dirname(self.selected_file) == temp_dir
|
||||
and os.path.dirname(self.selected_file) == cache_dir
|
||||
and self.selected_file.endswith(".txt")
|
||||
):
|
||||
self.input_box.clear_input()
|
||||
|
||||
@@ -3,6 +3,7 @@ import os
|
||||
import sys
|
||||
import platform
|
||||
import atexit
|
||||
import signal
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PyQt5.QtGui import QIcon
|
||||
from PyQt5.QtCore import qInstallMessageHandler, QtMsgType
|
||||
@@ -31,6 +32,16 @@ os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0"
|
||||
# Reset sleep states
|
||||
atexit.register(prevent_sleep_end)
|
||||
|
||||
|
||||
# Also handle signals (Ctrl+C, kill, etc.)
|
||||
def _cleanup_sleep(signum, frame):
|
||||
prevent_sleep_end()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
signal.signal(signal.SIGINT, _cleanup_sleep)
|
||||
signal.signal(signal.SIGTERM, _cleanup_sleep)
|
||||
|
||||
# Ensure sys.stdout and sys.stderr are valid in GUI mode
|
||||
if sys.stdout is None:
|
||||
sys.stdout = open(os.devnull, "w")
|
||||
|
||||
+44
-8
@@ -3,6 +3,7 @@ import sys
|
||||
import json
|
||||
import warnings
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import re
|
||||
from threading import Thread
|
||||
@@ -74,15 +75,32 @@ def get_user_config_path():
|
||||
if os.path.exists(custom_dir):
|
||||
config_dir = custom_dir
|
||||
else:
|
||||
config_dir = user_config_dir("abogen", appauthor=False, roaming=True)
|
||||
config_dir = user_config_dir(
|
||||
"abogen", appauthor=False, roaming=True, ensure_exists=True
|
||||
)
|
||||
else:
|
||||
# Windows and fallback case
|
||||
config_dir = user_config_dir("abogen", appauthor=False, roaming=True)
|
||||
config_dir = user_config_dir(
|
||||
"abogen", appauthor=False, roaming=True, ensure_exists=True
|
||||
)
|
||||
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
return os.path.join(config_dir, "config.json")
|
||||
|
||||
|
||||
# Define cache path
|
||||
def get_user_cache_path(folder=None):
|
||||
from platformdirs import user_cache_dir
|
||||
|
||||
cache_dir = user_cache_dir(
|
||||
"abogen", appauthor=False, opinion=True, ensure_exists=True
|
||||
)
|
||||
if folder:
|
||||
cache_dir = os.path.join(cache_dir, folder)
|
||||
# Ensure the directory exists
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
return cache_dir
|
||||
|
||||
|
||||
_sleep_procs = {"Darwin": None, "Linux": None} # Store sleep prevention processes
|
||||
|
||||
|
||||
@@ -222,13 +240,23 @@ def calculate_text_length(text):
|
||||
def get_gpu_acceleration(enabled):
|
||||
try:
|
||||
import torch
|
||||
from torch.cuda import is_available
|
||||
from torch.cuda import is_available as cuda_available
|
||||
|
||||
if not enabled:
|
||||
return "CUDA GPU available but using CPU.", False
|
||||
if is_available():
|
||||
return "GPU available but using CPU.", False
|
||||
|
||||
# Check for Apple Silicon MPS
|
||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||
if torch.backends.mps.is_available():
|
||||
return "MPS GPU available and enabled.", True
|
||||
else:
|
||||
return "MPS GPU not available on Apple Silicon. Using CPU.", False
|
||||
|
||||
# Check for CUDA
|
||||
if cuda_available():
|
||||
return "CUDA GPU available and enabled.", True
|
||||
# Gather more diagnostic info if CUDA is not available
|
||||
|
||||
# Gather CUDA diagnostic info if not available
|
||||
try:
|
||||
cuda_devices = torch.cuda.device_count()
|
||||
cuda_error = (
|
||||
@@ -240,11 +268,12 @@ def get_gpu_acceleration(enabled):
|
||||
cuda_error = str(e)
|
||||
return f"CUDA GPU is not available. Using CPU. ({cuda_error})", False
|
||||
except Exception as e:
|
||||
return f"Error checking CUDA GPU: {e}", False
|
||||
return f"Error checking GPU: {e}", False
|
||||
|
||||
|
||||
def prevent_sleep_start():
|
||||
from abogen.constants import PROGRAM_NAME
|
||||
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
import ctypes
|
||||
@@ -258,6 +287,8 @@ def prevent_sleep_start():
|
||||
# Add program name and reason for inhibition
|
||||
program_name = PROGRAM_NAME
|
||||
reason = "Prevent sleep during abogen process"
|
||||
# Only attempt to use systemd-inhibit if it's available on the system.
|
||||
if shutil.which("systemd-inhibit"):
|
||||
_sleep_procs["Linux"] = create_process(
|
||||
[
|
||||
"systemd-inhibit",
|
||||
@@ -269,6 +300,11 @@ def prevent_sleep_start():
|
||||
"infinity",
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Non-systemd distro or systemd tools not installed: skip inhibition rather than crash
|
||||
print(
|
||||
"systemd-inhibit not found: skipping sleep inhibition on this Linux system."
|
||||
)
|
||||
|
||||
|
||||
def prevent_sleep_end():
|
||||
|
||||
+9
-7
@@ -23,20 +23,22 @@ brew install ffmpeg
|
||||
sudo apt install ffmpeg
|
||||
```
|
||||
|
||||
## Create the Video (.webm)
|
||||
## Create the Video (.mp4) (Recommended)
|
||||
|
||||
Run this FFmpeg command to create the tiny video:
|
||||
Run this FFmpeg command to create the video:
|
||||
|
||||
```
|
||||
ffmpeg -loop 1 -framerate 24 -i bg.jpg -i audio.wav -vf "ass=subtitle.ass" -c:v libvpx-vp9 -b:v 0 -crf 30 -c:a libopus -shortest demo.webm
|
||||
ffmpeg -loop 1 -framerate 24 -i bg.jpg -i demo.wav -vf "ass=demo.ass" -c:v libx264 -pix_fmt yuv420p -preset slow -crf 18 -c:a aac -b:a 192k -movflags +faststart -shortest demo.mp4
|
||||
```
|
||||
|
||||
## For Higher Quality (But Larger) Video (.mp4)
|
||||
## For Smaller Filesize (.webm)
|
||||
|
||||
If you need better quality for distribution, use this command instead:
|
||||
If you need a smaller video file, use this command:
|
||||
|
||||
```
|
||||
ffmpeg -loop 1 -framerate 24 -i bg.jpg -i audio.wav -vf "ass=subtitle.ass" -c:v libx264 -preset slow -crf 18 -movflags +faststart -c:a copy -shortest demo.mp4
|
||||
ffmpeg -loop 1 -framerate 24 -i bg.jpg -i demo.wav -vf "ass=demo.ass" -c:v libvpx-vp9 -b:v 0 -crf 30 -c:a libopus -shortest demo.webm
|
||||
```
|
||||
|
||||
This creates an MP4 file that's compatible with more devices but larger in size.
|
||||
|
||||
|
||||
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 12 KiB |
+124
-124
@@ -4,127 +4,127 @@ ScriptType: v4.00+
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:00.27,0:00:00.83,Default,,100,100,0,,{\an5}Abogen
|
||||
Dialogue: 0,0:00:00.83,0:00:00.96,Default,,100,100,0,,{\an5}is
|
||||
Dialogue: 0,0:00:00.96,0:00:01.11,Default,,100,100,0,,{\an5}your
|
||||
Dialogue: 0,0:00:01.11,0:00:01.66,Default,,100,100,0,,{\an5}all-in-one
|
||||
Dialogue: 0,0:00:01.66,0:00:02.04,Default,,100,100,0,,{\an5}tool
|
||||
Dialogue: 0,0:00:02.04,0:00:02.17,Default,,100,100,0,,{\an5}for
|
||||
Dialogue: 0,0:00:02.17,0:00:02.53,Default,,100,100,0,,{\an5}turning
|
||||
Dialogue: 0,0:00:02.53,0:00:03.22,Default,,100,100,0,,{\an5}text
|
||||
Dialogue: 0,0:00:03.22,0:00:03.45,Default,,100,100,0,,{\an5}into
|
||||
Dialogue: 0,0:00:03.45,0:00:03.75,Default,,100,100,0,,{\an5}something
|
||||
Dialogue: 0,0:00:03.75,0:00:03.85,Default,,100,100,0,,{\an5}you
|
||||
Dialogue: 0,0:00:03.85,0:00:04.04,Default,,100,100,0,,{\an5}can
|
||||
Dialogue: 0,0:00:04.04,0:00:04.32,Default,,100,100,0,,{\an5}see
|
||||
Dialogue: 0,0:00:04.32,0:00:04.45,Default,,100,100,0,,{\an5}and
|
||||
Dialogue: 0,0:00:04.45,0:00:05.61,Default,,100,100,0,,{\an5}hear.
|
||||
Dialogue: 0,0:00:05.61,0:00:06.02,Default,,100,100,0,,{\an5}Easily
|
||||
Dialogue: 0,0:00:06.02,0:00:06.48,Default,,100,100,0,,{\an5}convert
|
||||
Dialogue: 0,0:00:06.48,0:00:07.31,Default,,100,100,0,,{\an5}ePub,
|
||||
Dialogue: 0,0:00:07.31,0:00:08.40,Default,,100,100,0,,{\an5}PDF,
|
||||
Dialogue: 0,0:00:08.40,0:00:08.55,Default,,100,100,0,,{\an5}or
|
||||
Dialogue: 0,0:00:08.55,0:00:08.90,Default,,100,100,0,,{\an5}text
|
||||
Dialogue: 0,0:00:08.90,0:00:09.21,Default,,100,100,0,,{\an5}file
|
||||
Dialogue: 0,0:00:09.21,0:00:09.46,Default,,100,100,0,,{\an5}into
|
||||
Dialogue: 0,0:00:09.46,0:00:10.34,Default,,100,100,0,,{\an5}audio
|
||||
Dialogue: 0,0:00:10.34,0:00:10.56,Default,,100,100,0,,{\an5}with
|
||||
Dialogue: 0,0:00:10.56,0:00:11.17,Default,,100,100,0,,{\an5}subtitles
|
||||
Dialogue: 0,0:00:11.17,0:00:11.32,Default,,100,100,0,,{\an5}that
|
||||
Dialogue: 0,0:00:11.32,0:00:11.51,Default,,100,100,0,,{\an5}light
|
||||
Dialogue: 0,0:00:11.51,0:00:11.71,Default,,100,100,0,,{\an5}up
|
||||
Dialogue: 0,0:00:11.71,0:00:12.05,Default,,100,100,0,,{\an5}every
|
||||
Dialogue: 0,0:00:12.05,0:00:12.40,Default,,100,100,0,,{\an5}word
|
||||
Dialogue: 0,0:00:12.40,0:00:12.51,Default,,100,100,0,,{\an5}as
|
||||
Dialogue: 0,0:00:12.51,0:00:12.65,Default,,100,100,0,,{\an5}it’s
|
||||
Dialogue: 0,0:00:12.65,0:00:14.21,Default,,100,100,0,,{\an5}spoken.
|
||||
Dialogue: 0,0:00:14.21,0:00:14.32,Default,,100,100,0,,{\an5}The
|
||||
Dialogue: 0,0:00:14.32,0:00:14.62,Default,,100,100,0,,{\an5}voice
|
||||
Dialogue: 0,0:00:14.62,0:00:14.77,Default,,100,100,0,,{\an5}you
|
||||
Dialogue: 0,0:00:14.77,0:00:15.65,Default,,100,100,0,,{\an5}hear,
|
||||
Dialogue: 0,0:00:15.65,0:00:15.76,Default,,100,100,0,,{\an5}and
|
||||
Dialogue: 0,0:00:15.76,0:00:15.86,Default,,100,100,0,,{\an5}the
|
||||
Dialogue: 0,0:00:15.86,0:00:16.44,Default,,100,100,0,,{\an5}subtitles
|
||||
Dialogue: 0,0:00:16.44,0:00:16.58,Default,,100,100,0,,{\an5}you
|
||||
Dialogue: 0,0:00:16.58,0:00:17.03,Default,,100,100,0,,{\an5}see
|
||||
Dialogue: 0,0:00:17.03,0:00:17.14,Default,,100,100,0,,{\an5}are
|
||||
Dialogue: 0,0:00:17.14,0:00:17.44,Default,,100,100,0,,{\an5}both
|
||||
Dialogue: 0,0:00:17.44,0:00:17.91,Default,,100,100,0,,{\an5}created
|
||||
Dialogue: 0,0:00:17.91,0:00:18.96,Default,,100,100,0,,{\an5}automatically
|
||||
Dialogue: 0,0:00:18.96,0:00:19.14,Default,,100,100,0,,{\an5}by
|
||||
Dialogue: 0,0:00:19.14,0:00:19.64,Default,,100,100,0,,{\an5}Abogen
|
||||
Dialogue: 0,0:00:19.64,0:00:20.00,Default,,100,100,0,,{\an5}using
|
||||
Dialogue: 0,0:00:20.00,0:00:21.14,Default,,100,100,0,,{\an5}Kokoro,
|
||||
Dialogue: 0,0:00:21.14,0:00:21.28,Default,,100,100,0,,{\an5}an
|
||||
Dialogue: 0,0:00:21.28,0:00:21.88,Default,,100,100,0,,{\an5}open-weight
|
||||
Dialogue: 0,0:00:21.88,0:00:22.52,Default,,100,100,0,,{\an5}TTS
|
||||
Dialogue: 0,0:00:22.52,0:00:23.18,Default,,100,100,0,,{\an5}model
|
||||
Dialogue: 0,0:00:23.18,0:00:23.38,Default,,100,100,0,,{\an5}with
|
||||
Dialogue: 0,0:00:23.38,0:00:24.01,Default,,100,100,0,,{\an5}82
|
||||
Dialogue: 0,0:00:24.01,0:00:24.37,Default,,100,100,0,,{\an5}million
|
||||
Dialogue: 0,0:00:24.37,0:00:25.98,Default,,100,100,0,,{\an5}parameters.
|
||||
Dialogue: 0,0:00:25.98,0:00:26.47,Default,,100,100,0,,{\an5}Everything
|
||||
Dialogue: 0,0:00:26.47,0:00:26.77,Default,,100,100,0,,{\an5}works
|
||||
Dialogue: 0,0:00:26.77,0:00:27.01,Default,,100,100,0,,{\an5}right
|
||||
Dialogue: 0,0:00:27.01,0:00:27.11,Default,,100,100,0,,{\an5}on
|
||||
Dialogue: 0,0:00:27.11,0:00:27.22,Default,,100,100,0,,{\an5}your
|
||||
Dialogue: 0,0:00:27.22,0:00:28.23,Default,,100,100,0,,{\an5}computer,
|
||||
Dialogue: 0,0:00:28.23,0:00:28.37,Default,,100,100,0,,{\an5}so
|
||||
Dialogue: 0,0:00:28.37,0:00:28.53,Default,,100,100,0,,{\an5}your
|
||||
Dialogue: 0,0:00:28.53,0:00:28.94,Default,,100,100,0,,{\an5}files
|
||||
Dialogue: 0,0:00:28.94,0:00:29.23,Default,,100,100,0,,{\an5}stay
|
||||
Dialogue: 0,0:00:29.23,0:00:29.88,Default,,100,100,0,,{\an5}private
|
||||
Dialogue: 0,0:00:29.88,0:00:30.05,Default,,100,100,0,,{\an5}and
|
||||
Dialogue: 0,0:00:30.05,0:00:31.16,Default,,100,100,0,,{\an5}secure.
|
||||
Dialogue: 0,0:00:31.50,0:00:31.63,Default,,100,100,0,,{\an5}The
|
||||
Dialogue: 0,0:00:31.63,0:00:31.97,Default,,100,100,0,,{\an5}easy
|
||||
Dialogue: 0,0:00:31.97,0:00:32.54,Default,,100,100,0,,{\an5}interface
|
||||
Dialogue: 0,0:00:32.54,0:00:32.81,Default,,100,100,0,,{\an5}lets
|
||||
Dialogue: 0,0:00:32.81,0:00:33.23,Default,,100,100,0,,{\an5}anyone
|
||||
Dialogue: 0,0:00:33.23,0:00:33.66,Default,,100,100,0,,{\an5}create
|
||||
Dialogue: 0,0:00:33.66,0:00:34.88,Default,,100,100,0,,{\an5}audiobooks,
|
||||
Dialogue: 0,0:00:34.88,0:00:35.54,Default,,100,100,0,,{\an5}voiceovers
|
||||
Dialogue: 0,0:00:35.54,0:00:35.70,Default,,100,100,0,,{\an5}for
|
||||
Dialogue: 0,0:00:35.70,0:00:36.68,Default,,100,100,0,,{\an5}Instagram,
|
||||
Dialogue: 0,0:00:36.68,0:00:37.23,Default,,100,100,0,,{\an5}YouTube
|
||||
Dialogue: 0,0:00:37.23,0:00:37.41,Default,,100,100,0,,{\an5}or
|
||||
Dialogue: 0,0:00:37.41,0:00:38.52,Default,,100,100,0,,{\an5}TikTok,
|
||||
Dialogue: 0,0:00:38.52,0:00:38.64,Default,,100,100,0,,{\an5}or
|
||||
Dialogue: 0,0:00:38.64,0:00:38.89,Default,,100,100,0,,{\an5}just
|
||||
Dialogue: 0,0:00:38.89,0:00:39.57,Default,,100,100,0,,{\an5}high-quality
|
||||
Dialogue: 0,0:00:39.57,0:00:40.39,Default,,100,100,0,,{\an5}text-to-speech
|
||||
Dialogue: 0,0:00:40.39,0:00:40.56,Default,,100,100,0,,{\an5}in
|
||||
Dialogue: 0,0:00:40.56,0:00:42.03,Default,,100,100,0,,{\an5}seconds.
|
||||
Dialogue: 0,0:00:42.03,0:00:42.32,Default,,100,100,0,,{\an5}Whether
|
||||
Dialogue: 0,0:00:42.32,0:00:42.47,Default,,100,100,0,,{\an5}you
|
||||
Dialogue: 0,0:00:42.47,0:00:42.67,Default,,100,100,0,,{\an5}want
|
||||
Dialogue: 0,0:00:42.67,0:00:42.77,Default,,100,100,0,,{\an5}to
|
||||
Dialogue: 0,0:00:42.77,0:00:43.43,Default,,100,100,0,,{\an5}listen,
|
||||
Dialogue: 0,0:00:43.43,0:00:43.77,Default,,100,100,0,,{\an5}create
|
||||
Dialogue: 0,0:00:43.77,0:00:44.70,Default,,100,100,0,,{\an5}content,
|
||||
Dialogue: 0,0:00:44.70,0:00:44.85,Default,,100,100,0,,{\an5}or
|
||||
Dialogue: 0,0:00:44.85,0:00:45.07,Default,,100,100,0,,{\an5}bring
|
||||
Dialogue: 0,0:00:45.07,0:00:45.22,Default,,100,100,0,,{\an5}your
|
||||
Dialogue: 0,0:00:45.22,0:00:45.54,Default,,100,100,0,,{\an5}words
|
||||
Dialogue: 0,0:00:45.54,0:00:45.67,Default,,100,100,0,,{\an5}to
|
||||
Dialogue: 0,0:00:45.67,0:00:46.58,Default,,100,100,0,,{\an5}life,
|
||||
Dialogue: 0,0:00:46.58,0:00:47.10,Default,,100,100,0,,{\an5}Abogen
|
||||
Dialogue: 0,0:00:47.10,0:00:47.37,Default,,100,100,0,,{\an5}makes
|
||||
Dialogue: 0,0:00:47.37,0:00:47.53,Default,,100,100,0,,{\an5}it
|
||||
Dialogue: 0,0:00:47.53,0:00:48.31,Default,,100,100,0,,{\an5}fast,
|
||||
Dialogue: 0,0:00:48.31,0:00:48.89,Default,,100,100,0,,{\an5}fun,
|
||||
Dialogue: 0,0:00:48.89,0:00:49.02,Default,,100,100,0,,{\an5}and
|
||||
Dialogue: 0,0:00:49.02,0:00:50.62,Default,,100,100,0,,{\an5}effortless.
|
||||
Dialogue: 0,0:00:50.62,0:00:51.35,Default,,100,100,0,,{\an5}Experience
|
||||
Dialogue: 0,0:00:51.35,0:00:51.47,Default,,100,100,0,,{\an5}your
|
||||
Dialogue: 0,0:00:51.47,0:00:52.23,Default,,100,100,0,,{\an5}stories,
|
||||
Dialogue: 0,0:00:52.23,0:00:52.97,Default,,100,100,0,,{\an5}scripts,
|
||||
Dialogue: 0,0:00:52.97,0:00:53.11,Default,,100,100,0,,{\an5}and
|
||||
Dialogue: 0,0:00:53.11,0:00:53.64,Default,,100,100,0,,{\an5}ideas
|
||||
Dialogue: 0,0:00:53.64,0:00:53.73,Default,,100,100,0,,{\an5}in
|
||||
Dialogue: 0,0:00:53.73,0:00:53.83,Default,,100,100,0,,{\an5}a
|
||||
Dialogue: 0,0:00:53.83,0:00:54.14,Default,,100,100,0,,{\an5}whole
|
||||
Dialogue: 0,0:00:54.14,0:00:54.35,Default,,100,100,0,,{\an5}new
|
||||
Dialogue: 0,0:00:54.35,0:00:54.64,Default,,100,100,0,,{\an5}way
|
||||
Dialogue: 0,0:00:54.64,0:00:54.79,Default,,100,100,0,,{\an5}with
|
||||
Dialogue: 0,0:00:54.79,0:00:55.85,Default,,100,100,0,,{\an5}Abogen.
|
||||
Dialogue: 0,0:00:00.35,0:00:00.93,Default,,90,90,0,,{\an5}Abogen
|
||||
Dialogue: 0,0:00:00.93,0:00:01.07,Default,,90,90,0,,{\an5}is
|
||||
Dialogue: 0,0:00:01.07,0:00:01.27,Default,,90,90,0,,{\an5}your
|
||||
Dialogue: 0,0:00:01.27,0:00:01.86,Default,,90,90,0,,{\an5}all-in-one
|
||||
Dialogue: 0,0:00:01.86,0:00:02.22,Default,,90,90,0,,{\an5}tool
|
||||
Dialogue: 0,0:00:02.22,0:00:02.35,Default,,90,90,0,,{\an5}for
|
||||
Dialogue: 0,0:00:02.35,0:00:02.73,Default,,90,90,0,,{\an5}turning
|
||||
Dialogue: 0,0:00:02.73,0:00:03.13,Default,,90,90,0,,{\an5}text
|
||||
Dialogue: 0,0:00:03.13,0:00:03.35,Default,,90,90,0,,{\an5}into
|
||||
Dialogue: 0,0:00:03.35,0:00:03.70,Default,,90,90,0,,{\an5}something
|
||||
Dialogue: 0,0:00:03.70,0:00:03.79,Default,,90,90,0,,{\an5}you
|
||||
Dialogue: 0,0:00:03.79,0:00:03.98,Default,,90,90,0,,{\an5}can
|
||||
Dialogue: 0,0:00:03.98,0:00:04.25,Default,,90,90,0,,{\an5}see
|
||||
Dialogue: 0,0:00:04.25,0:00:04.37,Default,,90,90,0,,{\an5}and
|
||||
Dialogue: 0,0:00:04.37,0:00:05.37,Default,,90,90,0,,{\an5}hear.
|
||||
Dialogue: 0,0:00:05.37,0:00:05.79,Default,,90,90,0,,{\an5}Easily
|
||||
Dialogue: 0,0:00:05.79,0:00:06.23,Default,,90,90,0,,{\an5}convert
|
||||
Dialogue: 0,0:00:06.23,0:00:07.11,Default,,90,90,0,,{\an5}ePub,
|
||||
Dialogue: 0,0:00:07.11,0:00:08.17,Default,,90,90,0,,{\an5}PDF,
|
||||
Dialogue: 0,0:00:08.17,0:00:08.33,Default,,90,90,0,,{\an5}or
|
||||
Dialogue: 0,0:00:08.33,0:00:08.67,Default,,90,90,0,,{\an5}text
|
||||
Dialogue: 0,0:00:08.67,0:00:08.98,Default,,90,90,0,,{\an5}file
|
||||
Dialogue: 0,0:00:08.98,0:00:09.23,Default,,90,90,0,,{\an5}into
|
||||
Dialogue: 0,0:00:09.23,0:00:09.91,Default,,90,90,0,,{\an5}audio
|
||||
Dialogue: 0,0:00:09.91,0:00:10.09,Default,,90,90,0,,{\an5}with
|
||||
Dialogue: 0,0:00:10.09,0:00:10.69,Default,,90,90,0,,{\an5}subtitles
|
||||
Dialogue: 0,0:00:10.69,0:00:10.87,Default,,90,90,0,,{\an5}that
|
||||
Dialogue: 0,0:00:10.87,0:00:11.06,Default,,90,90,0,,{\an5}light
|
||||
Dialogue: 0,0:00:11.06,0:00:11.26,Default,,90,90,0,,{\an5}up
|
||||
Dialogue: 0,0:00:11.26,0:00:11.59,Default,,90,90,0,,{\an5}every
|
||||
Dialogue: 0,0:00:11.59,0:00:11.90,Default,,90,90,0,,{\an5}word
|
||||
Dialogue: 0,0:00:11.90,0:00:12.02,Default,,90,90,0,,{\an5}as
|
||||
Dialogue: 0,0:00:12.02,0:00:12.17,Default,,90,90,0,,{\an5}it’s
|
||||
Dialogue: 0,0:00:12.17,0:00:13.46,Default,,90,90,0,,{\an5}spoken.
|
||||
Dialogue: 0,0:00:13.46,0:00:13.59,Default,,90,90,0,,{\an5}The
|
||||
Dialogue: 0,0:00:13.59,0:00:13.92,Default,,90,90,0,,{\an5}voice
|
||||
Dialogue: 0,0:00:13.92,0:00:14.07,Default,,90,90,0,,{\an5}you
|
||||
Dialogue: 0,0:00:14.07,0:00:14.86,Default,,90,90,0,,{\an5}hear,
|
||||
Dialogue: 0,0:00:14.86,0:00:14.98,Default,,90,90,0,,{\an5}and
|
||||
Dialogue: 0,0:00:14.98,0:00:15.08,Default,,90,90,0,,{\an5}the
|
||||
Dialogue: 0,0:00:15.08,0:00:15.67,Default,,90,90,0,,{\an5}subtitles
|
||||
Dialogue: 0,0:00:15.67,0:00:15.81,Default,,90,90,0,,{\an5}you
|
||||
Dialogue: 0,0:00:15.81,0:00:16.13,Default,,90,90,0,,{\an5}see
|
||||
Dialogue: 0,0:00:16.13,0:00:16.27,Default,,90,90,0,,{\an5}are
|
||||
Dialogue: 0,0:00:16.27,0:00:16.57,Default,,90,90,0,,{\an5}both
|
||||
Dialogue: 0,0:00:16.57,0:00:17.03,Default,,90,90,0,,{\an5}created
|
||||
Dialogue: 0,0:00:17.03,0:00:17.96,Default,,90,90,0,,{\an5}automatically
|
||||
Dialogue: 0,0:00:17.96,0:00:18.14,Default,,90,90,0,,{\an5}by
|
||||
Dialogue: 0,0:00:18.14,0:00:18.67,Default,,90,90,0,,{\an5}Abogen
|
||||
Dialogue: 0,0:00:18.67,0:00:19.02,Default,,90,90,0,,{\an5}using
|
||||
Dialogue: 0,0:00:19.02,0:00:20.10,Default,,90,90,0,,{\an5}Kokoro,
|
||||
Dialogue: 0,0:00:20.10,0:00:20.27,Default,,90,90,0,,{\an5}an
|
||||
Dialogue: 0,0:00:20.27,0:00:20.88,Default,,90,90,0,,{\an5}open-weight
|
||||
Dialogue: 0,0:00:20.88,0:00:21.55,Default,,90,90,0,,{\an5}TTS
|
||||
Dialogue: 0,0:00:21.55,0:00:21.94,Default,,90,90,0,,{\an5}model
|
||||
Dialogue: 0,0:00:21.94,0:00:22.14,Default,,90,90,0,,{\an5}with
|
||||
Dialogue: 0,0:00:22.14,0:00:22.78,Default,,90,90,0,,{\an5}82
|
||||
Dialogue: 0,0:00:22.78,0:00:23.14,Default,,90,90,0,,{\an5}million
|
||||
Dialogue: 0,0:00:23.14,0:00:24.55,Default,,90,90,0,,{\an5}parameters.
|
||||
Dialogue: 0,0:00:24.55,0:00:25.05,Default,,90,90,0,,{\an5}Everything
|
||||
Dialogue: 0,0:00:25.05,0:00:25.35,Default,,90,90,0,,{\an5}works
|
||||
Dialogue: 0,0:00:25.35,0:00:25.58,Default,,90,90,0,,{\an5}right
|
||||
Dialogue: 0,0:00:25.58,0:00:25.68,Default,,90,90,0,,{\an5}on
|
||||
Dialogue: 0,0:00:25.68,0:00:25.80,Default,,90,90,0,,{\an5}your
|
||||
Dialogue: 0,0:00:25.80,0:00:26.69,Default,,90,90,0,,{\an5}computer,
|
||||
Dialogue: 0,0:00:26.69,0:00:26.87,Default,,90,90,0,,{\an5}so
|
||||
Dialogue: 0,0:00:26.87,0:00:27.06,Default,,90,90,0,,{\an5}your
|
||||
Dialogue: 0,0:00:27.06,0:00:27.47,Default,,90,90,0,,{\an5}files
|
||||
Dialogue: 0,0:00:27.47,0:00:27.73,Default,,90,90,0,,{\an5}stay
|
||||
Dialogue: 0,0:00:27.73,0:00:28.28,Default,,90,90,0,,{\an5}private
|
||||
Dialogue: 0,0:00:28.28,0:00:28.44,Default,,90,90,0,,{\an5}and
|
||||
Dialogue: 0,0:00:28.44,0:00:29.51,Default,,90,90,0,,{\an5}secure.
|
||||
Dialogue: 0,0:00:29.90,0:00:30.03,Default,,90,90,0,,{\an5}The
|
||||
Dialogue: 0,0:00:30.03,0:00:30.37,Default,,90,90,0,,{\an5}easy
|
||||
Dialogue: 0,0:00:30.37,0:00:30.95,Default,,90,90,0,,{\an5}interface
|
||||
Dialogue: 0,0:00:30.95,0:00:31.23,Default,,90,90,0,,{\an5}lets
|
||||
Dialogue: 0,0:00:31.23,0:00:31.71,Default,,90,90,0,,{\an5}anyone
|
||||
Dialogue: 0,0:00:31.71,0:00:32.13,Default,,90,90,0,,{\an5}create
|
||||
Dialogue: 0,0:00:32.13,0:00:33.33,Default,,90,90,0,,{\an5}audiobooks,
|
||||
Dialogue: 0,0:00:33.33,0:00:34.07,Default,,90,90,0,,{\an5}voiceovers
|
||||
Dialogue: 0,0:00:34.07,0:00:34.23,Default,,90,90,0,,{\an5}for
|
||||
Dialogue: 0,0:00:34.23,0:00:35.30,Default,,90,90,0,,{\an5}Instagram,
|
||||
Dialogue: 0,0:00:35.30,0:00:35.93,Default,,90,90,0,,{\an5}YouTube
|
||||
Dialogue: 0,0:00:35.93,0:00:36.11,Default,,90,90,0,,{\an5}or
|
||||
Dialogue: 0,0:00:36.11,0:00:36.92,Default,,90,90,0,,{\an5}TikTok,
|
||||
Dialogue: 0,0:00:36.92,0:00:37.07,Default,,90,90,0,,{\an5}or
|
||||
Dialogue: 0,0:00:37.07,0:00:37.30,Default,,90,90,0,,{\an5}just
|
||||
Dialogue: 0,0:00:37.30,0:00:38.00,Default,,90,90,0,,{\an5}high-quality
|
||||
Dialogue: 0,0:00:38.00,0:00:38.82,Default,,90,90,0,,{\an5}text-to-speech
|
||||
Dialogue: 0,0:00:38.82,0:00:38.97,Default,,90,90,0,,{\an5}in
|
||||
Dialogue: 0,0:00:38.97,0:00:40.16,Default,,90,90,0,,{\an5}seconds.
|
||||
Dialogue: 0,0:00:40.16,0:00:40.45,Default,,90,90,0,,{\an5}Whether
|
||||
Dialogue: 0,0:00:40.45,0:00:40.57,Default,,90,90,0,,{\an5}you
|
||||
Dialogue: 0,0:00:40.57,0:00:40.77,Default,,90,90,0,,{\an5}want
|
||||
Dialogue: 0,0:00:40.77,0:00:40.87,Default,,90,90,0,,{\an5}to
|
||||
Dialogue: 0,0:00:40.87,0:00:41.58,Default,,90,90,0,,{\an5}listen,
|
||||
Dialogue: 0,0:00:41.58,0:00:41.92,Default,,90,90,0,,{\an5}create
|
||||
Dialogue: 0,0:00:41.92,0:00:42.65,Default,,90,90,0,,{\an5}content,
|
||||
Dialogue: 0,0:00:42.65,0:00:42.79,Default,,90,90,0,,{\an5}or
|
||||
Dialogue: 0,0:00:42.79,0:00:43.01,Default,,90,90,0,,{\an5}bring
|
||||
Dialogue: 0,0:00:43.01,0:00:43.12,Default,,90,90,0,,{\an5}your
|
||||
Dialogue: 0,0:00:43.12,0:00:43.42,Default,,90,90,0,,{\an5}words
|
||||
Dialogue: 0,0:00:43.42,0:00:43.54,Default,,90,90,0,,{\an5}to
|
||||
Dialogue: 0,0:00:43.54,0:00:44.33,Default,,90,90,0,,{\an5}life,
|
||||
Dialogue: 0,0:00:44.33,0:00:44.90,Default,,90,90,0,,{\an5}Abogen
|
||||
Dialogue: 0,0:00:44.90,0:00:45.17,Default,,90,90,0,,{\an5}makes
|
||||
Dialogue: 0,0:00:45.17,0:00:45.35,Default,,90,90,0,,{\an5}it
|
||||
Dialogue: 0,0:00:45.35,0:00:46.12,Default,,90,90,0,,{\an5}fast,
|
||||
Dialogue: 0,0:00:46.12,0:00:46.65,Default,,90,90,0,,{\an5}fun,
|
||||
Dialogue: 0,0:00:46.65,0:00:46.77,Default,,90,90,0,,{\an5}and
|
||||
Dialogue: 0,0:00:46.77,0:00:48.07,Default,,90,90,0,,{\an5}effortless.
|
||||
Dialogue: 0,0:00:48.07,0:00:48.76,Default,,90,90,0,,{\an5}Experience
|
||||
Dialogue: 0,0:00:48.76,0:00:48.87,Default,,90,90,0,,{\an5}your
|
||||
Dialogue: 0,0:00:48.87,0:00:49.61,Default,,90,90,0,,{\an5}stories,
|
||||
Dialogue: 0,0:00:49.61,0:00:50.15,Default,,90,90,0,,{\an5}scripts,
|
||||
Dialogue: 0,0:00:50.15,0:00:50.29,Default,,90,90,0,,{\an5}and
|
||||
Dialogue: 0,0:00:50.29,0:00:50.87,Default,,90,90,0,,{\an5}ideas
|
||||
Dialogue: 0,0:00:50.87,0:00:50.97,Default,,90,90,0,,{\an5}in
|
||||
Dialogue: 0,0:00:50.97,0:00:51.10,Default,,90,90,0,,{\an5}a
|
||||
Dialogue: 0,0:00:51.10,0:00:51.46,Default,,90,90,0,,{\an5}whole
|
||||
Dialogue: 0,0:00:51.46,0:00:51.65,Default,,90,90,0,,{\an5}new
|
||||
Dialogue: 0,0:00:51.65,0:00:51.95,Default,,90,90,0,,{\an5}way
|
||||
Dialogue: 0,0:00:51.95,0:00:52.10,Default,,90,90,0,,{\an5}with
|
||||
Dialogue: 0,0:00:52.10,0:00:53.20,Default,,90,90,0,,{\an5}Abogen.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -15,6 +15,7 @@ keywords = ["audiobook", "epub", "pdf", "text-to-speech", "subtitle", "tts", "ko
|
||||
dependencies = [
|
||||
"PyQt5>=5.15.11",
|
||||
"kokoro>=0.9.4",
|
||||
"misaki[zh]>=0.9.4",
|
||||
"ebooklib>=0.19",
|
||||
"beautifulsoup4>=4.13.4",
|
||||
"PyMuPDF>=1.25.5",
|
||||
|
||||
Reference in New Issue
Block a user