78 lines
3.5 KiB
Python
78 lines
3.5 KiB
Python
"""Adapter to the existing local VoiceStudio audiobook API; no local TTS model."""
|
|
import json
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
import shutil
|
|
from urllib.request import Request, urlopen
|
|
|
|
BASE = os.environ.get('VOICESTUDIO_URL', 'http://127.0.0.1:3900').rstrip('/')
|
|
VOICE = os.environ.get('VOICESTUDIO_VOICE', 'db7928a3')
|
|
OUTPUTS = Path(os.environ.get('VOICESTUDIO_OUTPUTS', '/home/sevenhill/voicestudio-data/outputs'))
|
|
|
|
|
|
def split_sections(sections, limit=1600):
|
|
"""Bound GPU tasks while preserving all non-whitespace text and its order."""
|
|
result = []
|
|
for heading, text in sections:
|
|
text = re.sub(r'\s+', ' ', text).strip()
|
|
parts = []
|
|
while len(text) > limit:
|
|
cut = max((match.end() for match in re.finditer(r'[.!?…]\s+', text[:limit])
|
|
if match.end() >= limit // 2), default=0)
|
|
if not cut:
|
|
cut = text.rfind(' ', 0, limit + 1)
|
|
if cut <= 0:
|
|
cut = limit
|
|
parts.append(text[:cut].strip())
|
|
text = text[cut:].lstrip()
|
|
if text:
|
|
parts.append(text)
|
|
for index, part in enumerate(parts, 1):
|
|
label = heading if len(parts) == 1 else f'{heading} · {index}/{len(parts)}'
|
|
result.append((label, part))
|
|
return result
|
|
|
|
|
|
def check_available():
|
|
with urlopen(BASE + '/health', timeout=3) as response:
|
|
if response.status != 200:
|
|
raise RuntimeError('VoiceStudio unavailable')
|
|
|
|
|
|
def render_audiobook(sections, title, author, destination, on_event, cancelled):
|
|
check_available()
|
|
payload = {
|
|
'text': '\n\n'.join('# ' + heading.replace('\n', ' ') + '\n' + text for heading, text in sections),
|
|
'default_voice': VOICE, 'language': 'ru', 'format': 'm4b',
|
|
'metadata': {'title': title, 'author': author},
|
|
}
|
|
request = Request(BASE + '/audiobook', data=json.dumps(payload).encode(),
|
|
headers={'Content-Type': 'application/json'})
|
|
# Assembly may take up to six hours on CMP, with no SSE events meanwhile.
|
|
with urlopen(request, timeout=28800) as response:
|
|
for line in response:
|
|
if cancelled():
|
|
raise RuntimeError('Изготовление отменено')
|
|
if not line.startswith(b'data:'):
|
|
continue
|
|
event = json.loads(line[5:].strip())
|
|
if event.get('type') == 'error':
|
|
raise RuntimeError(event.get('message') or event.get('error') or str(event))
|
|
on_event(event)
|
|
if event.get('type') == 'done':
|
|
if event.get('failed_chapters'):
|
|
raise RuntimeError('VoiceStudio не озвучил все главы')
|
|
source = (OUTPUTS / event['output']).resolve()
|
|
if not source.is_relative_to(OUTPUTS.resolve()) or not source.is_file():
|
|
raise RuntimeError('VoiceStudio вернул недоступный файл')
|
|
# M4B and M4A are the same MP4/AAC container; preserve embedded chapters.
|
|
with source.open('rb') as audio:
|
|
if b'ftyp' not in audio.read(32):
|
|
raise RuntimeError('VoiceStudio вернул повреждённый аудиофайл')
|
|
shutil.copyfile(source, destination)
|
|
from audio_cleanup import remember_output
|
|
remember_output(destination, source)
|
|
return event
|
|
raise RuntimeError('VoiceStudio завершил поток без готовой аудиокниги')
|