Files
Aletheia/server/audiobook/voicestudio_client.py
T

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 завершил поток без готовой аудиокниги')