Repository navigation
Expand file tree
/
Copy pathexport.py
More file actions
158 lines (133 loc) · 6.06 KB
/
Copy pathexport.py
File metadata and controls
158 lines (133 loc) · 6.06 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
"""Turns a chapter or a whole book into files to keep: audio in several formats, or plain text.
job = start(tts, book, scope="book", chapter=0, kind="mp3", split=True, speed=1.0)
job.progress # 0..1
job.state # "working", "ready", "failed" or "cancelled"
job.path, job.name
A book can take minutes, so the work runs in a thread and the caller polls.
"""
import re
import shutil
import tempfile
import threading
import uuid
import zipfile
from pathlib import Path
import numpy as np
# kind: (extension, soundfile format, subtype, sample rate); speech is small and clear at 24 kHz
AUDIO = {
"mp3": ("mp3", "MP3", "MPEG_LAYER_III", 24000),
"ogg": ("ogg", "OGG", "VORBIS", 24000),
"opus": ("opus", "OGG", "OPUS", 24000),
"flac": ("flac", "FLAC", "PCM_16", 48000),
"wav": ("wav", "WAV", "PCM_16", 48000),
}
KINDS = (*AUDIO, "txt")
BATCH = 32
SENTENCE_PAUSE = 0.15
PARAGRAPH_PAUSE = 0.5
CHAPTER_PAUSE = 1.2
jobs = {}
def safe(name):
"""A file name that every system accepts."""
return " ".join(re.sub(r'[\\/:*?"<>|\x00-\x1f]+', " ", name).split()).strip(" .")[:80] or "EMA Reader"
def chapter_name(book, number):
title = book["chapters"][number]["title"]
return f"{number + 1:02d} {title}" if title else f"{number + 1:02d}"
def chapter_text(book, number):
chapter = book["chapters"][number]
head = [chapter["title"], ""] if chapter["title"] else []
return "\n\n".join(head[:1] + [" ".join(p) for p in chapter["paragraphs"]])
class Job:
def __init__(self, tts, book, scope, chapter, kind, split, speed):
self.id = uuid.uuid4().hex[:12]
self.tts, self.book, self.kind, self.speed = tts, book, kind, speed
self.numbers = [chapter] if scope == "chapter" else list(range(len(book["chapters"])))
self.split = split and len(self.numbers) > 1
self.folder = Path(tempfile.mkdtemp(prefix="ema-reader-"))
self.state, self.progress, self.error = "working", 0.0, None
self.cancelled = threading.Event()
ext = "txt" if kind == "txt" else AUDIO[kind][0]
base = safe(book["title"])
if scope == "chapter" and len(book["chapters"]) > 1:
base = safe(f"{book['title']} - {chapter_name(book, chapter)}")
self.name = f"{base}.zip" if self.split else f"{base}.{ext}"
self.path = self.folder / ("out.zip" if self.split else f"out.{ext}")
self.total = sum(len(p) for n in self.numbers for p in book["chapters"][n]["paragraphs"]) or 1
self.done = 0
def run(self):
try:
if self.kind == "txt":
self.write_text()
else:
self.write_audio()
self.state = "cancelled" if self.cancelled.is_set() else "ready"
self.progress = 1.0
except Exception as e:
self.state, self.error = "failed", str(e)
if self.state != "ready":
self.clean()
def write_text(self):
ext = "txt"
if self.split:
with zipfile.ZipFile(self.path, "w", zipfile.ZIP_DEFLATED) as z:
for n in self.numbers:
z.writestr(f"{safe(chapter_name(self.book, n))}.{ext}", chapter_text(self.book, n))
else:
self.path.write_text("\n\n\n".join(chapter_text(self.book, n) for n in self.numbers), encoding="utf-8")
def write_audio(self):
import soundfile
ext, fmt, subtype, rate = AUDIO[self.kind]
def open_file(path):
return soundfile.SoundFile(str(path), "w", samplerate=rate, channels=1, format=fmt, subtype=subtype)
def silence(seconds):
return np.zeros(int(seconds * rate), dtype="float32")
def speak(out, number):
chapter = self.book["chapters"][number]
texts = [(s, i == len(p) - 1) for p in chapter["paragraphs"] for i, s in enumerate(p)]
if chapter["title"]:
texts.insert(0, (chapter["title"], True)) # the chapter's name is read out too
self.total += 1
for start in range(0, len(texts), BATCH):
if self.cancelled.is_set():
return
batch = texts[start : start + BATCH]
speeches = self.tts.say([s for s, _ in batch], speed=self.speed, sample_rate=rate)
for speech, (_, ends_paragraph) in zip(speeches, batch):
out.write(np.clip(speech.audio, -1, 1))
out.write(silence(PARAGRAPH_PAUSE if ends_paragraph else SENTENCE_PAUSE))
self.done += len(batch)
self.progress = min(0.99, self.done / self.total)
if self.split:
parts = self.folder / "parts"
parts.mkdir()
with zipfile.ZipFile(self.path, "w", zipfile.ZIP_STORED) as z:
for n in self.numbers:
part = parts / f"{n}.{ext}"
with open_file(part) as out:
speak(out, n)
if self.cancelled.is_set():
return
z.write(part, f"{safe(chapter_name(self.book, n))}.{ext}")
part.unlink()
else:
with open_file(self.path) as out:
for i, n in enumerate(self.numbers):
if i:
out.write(silence(CHAPTER_PAUSE))
speak(out, n)
def clean(self):
shutil.rmtree(self.folder, ignore_errors=True)
jobs.pop(self.id, None)
def start(tts, book, scope="chapter", chapter=0, kind="mp3", split=False, speed=1.0):
if kind not in KINDS:
raise ValueError(f"format şunlardan biri olmalı: {', '.join(KINDS)}")
if scope not in ("chapter", "book"):
raise ValueError("scope, chapter ya da book olmalı")
if not 0 <= chapter < len(book["chapters"]):
raise ValueError("Böyle bir bölüm yok.")
if not 0.25 <= speed <= 4:
raise ValueError("speed 0.25 ile 4 arasında bir sayı olmalı")
job = Job(tts, book, scope, chapter, kind, bool(split), speed)
jobs[job.id] = job
threading.Thread(target=job.run, daemon=True).start()
return job