File: /home/kiwerkzeuge.de/public_html/service/speaches-service-manager.php
<?php
declare(strict_types=1);
function speaches_config(array $config): array
{
$config['remote_endpoint'] = rtrim((string)($config['remote_endpoint'] ?? ''), '/');
$config['service_unit'] = trim((string)($config['service_unit'] ?? 'speaches.service')) ?: 'speaches.service';
$config['service_activity_file'] = trim((string)($config['service_activity_file'] ?? '/tmp/speaches-last-activity.txt')) ?: '/tmp/speaches-last-activity.txt';
$config['service_start_timeout'] = max(10, min(900, (int)($config['service_start_timeout'] ?? 240)));
$config['auto_stop_idle_seconds'] = max(30, min(7200, (int)($config['auto_stop_idle_seconds'] ?? 300)));
$config['output_dir'] = trim((string)($config['output_dir'] ?? '')) ?: '/home/kiwerkzeuge.de/public_html/uploads/speaches';
$config['output_url_base'] = '/' . trim((string)($config['output_url_base'] ?? '/uploads/speaches'), '/');
$config['public_base_url'] = rtrim((string)($config['public_base_url'] ?? 'https://kiwerkzeuge.de'), '/');
$config['default_model'] = trim((string)($config['default_model'] ?? '')) ?: 'speaches-ai/Kokoro-82M-v1.0-ONNX';
$config['default_voice'] = trim((string)($config['default_voice'] ?? '')) ?: 'af_heart';
$config['default_response_format'] = speaches_normalize_response_format((string)($config['default_response_format'] ?? 'wav'));
$config['max_text_chars'] = max(50, min(20000, (int)($config['max_text_chars'] ?? 2000)));
$config['execute_secret'] = (string)($config['execute_secret'] ?? '');
return $config;
}
function speaches_service_touch_activity(array $config): string
{
$token = time() . '-' . bin2hex(random_bytes(4));
@file_put_contents((string)$config['service_activity_file'], $token, LOCK_EX);
return $token;
}
function speaches_service_run_systemctl(array $config, string $action): bool
{
if (!in_array($action, ['start', 'stop', 'restart'], true)) {
return false;
}
$unitArg = escapeshellarg((string)$config['service_unit']);
$cmds = [
"sudo -n /bin/systemctl {$action} {$unitArg}",
"sudo -n /usr/bin/systemctl {$action} {$unitArg}",
"/bin/systemctl {$action} {$unitArg}",
"/usr/bin/systemctl {$action} {$unitArg}",
];
foreach ($cmds as $cmd) {
@exec($cmd . ' >/dev/null 2>&1', $out, $code);
if ($code === 0) {
return true;
}
}
return false;
}
function speaches_service_ping(array $config, int $timeoutSeconds = 4): array
{
$url = rtrim((string)$config['remote_endpoint'], '/') . '/v1/models';
if ($url === '/v1/models') {
return ['ok' => false, 'status' => 0, 'error' => 'remote_endpoint fehlt'];
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => max(1, $timeoutSeconds),
CURLOPT_CONNECTTIMEOUT => min(3, max(1, $timeoutSeconds)),
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$error = (string)curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false) {
return ['ok' => false, 'status' => $status, 'error' => ($error !== '' ? $error : 'nicht erreichbar')];
}
if ($status >= 200 && $status < 400) {
$decoded = json_decode((string)$response, true);
return ['ok' => true, 'status' => $status, 'payload' => is_array($decoded) ? $decoded : null];
}
return ['ok' => false, 'status' => $status, 'error' => 'HTTP ' . $status];
}
function speaches_service_ensure_ready(array $config): array
{
$ping = speaches_service_ping($config, 3);
if (($ping['ok'] ?? false) === true) {
speaches_service_touch_activity($config);
return ['ok' => true, 'started' => false, 'message' => 'Bereit'];
}
if (!($config['service_control_enabled'] ?? true)) {
return ['ok' => false, 'started' => false, 'message' => 'Speaches ist offline.', 'detail' => (string)($ping['error'] ?? '')];
}
if (!speaches_service_run_systemctl($config, 'start')) {
return ['ok' => false, 'started' => false, 'message' => 'Speaches konnte nicht gestartet werden.', 'detail' => 'systemctl start fehlgeschlagen'];
}
$deadline = microtime(true) + (int)$config['service_start_timeout'];
$lastError = '';
while (microtime(true) < $deadline) {
usleep(500000);
$probe = speaches_service_ping($config, 4);
if (($probe['ok'] ?? false) === true) {
speaches_service_touch_activity($config);
return ['ok' => true, 'started' => true, 'message' => 'Speaches gestartet'];
}
$lastError = (string)($probe['error'] ?? '');
}
return ['ok' => false, 'started' => true, 'message' => 'Speaches startet noch oder ist nicht erreichbar.', 'detail' => $lastError];
}
function speaches_service_schedule_auto_stop(array $config): void
{
if (!($config['service_control_enabled'] ?? true)) {
return;
}
$token = speaches_service_touch_activity($config);
$idleSeconds = (int)$config['auto_stop_idle_seconds'];
$unitArg = escapeshellarg((string)$config['service_unit']);
$activityFileArg = escapeshellarg((string)$config['service_activity_file']);
$tokenArg = escapeshellarg($token);
$cmd = '(sleep ' . $idleSeconds . '; CUR=$(cat ' . $activityFileArg . ' 2>/dev/null || true); if [ "$CUR" = ' . $tokenArg . ' ]; then '
. 'sudo -n /bin/systemctl stop ' . $unitArg . ' >/dev/null 2>&1 || '
. 'sudo -n /usr/bin/systemctl stop ' . $unitArg . ' >/dev/null 2>&1 || '
. '/bin/systemctl stop ' . $unitArg . ' >/dev/null 2>&1 || '
. '/usr/bin/systemctl stop ' . $unitArg . ' >/dev/null 2>&1; '
. 'fi) >/dev/null 2>&1 &';
@exec($cmd);
}
function speaches_is_local_request(): bool
{
$remote = (string)($_SERVER['REMOTE_ADDR'] ?? '');
return $remote === '127.0.0.1' || $remote === '::1';
}
function speaches_require_exec_auth(array $config, array $body): void
{
if (speaches_is_local_request()) {
return;
}
$secret = (string)($body['_gpuq_secret'] ?? '');
$expected = (string)($config['execute_secret'] ?? '');
if ($expected !== '' && hash_equals($expected, $secret)) {
return;
}
http_response_code(403);
echo json_encode(['ok' => false, 'message' => 'Forbidden']);
exit;
}
function speaches_http_json(string $method, string $url, ?array $payload, int $timeoutSeconds = 60): array
{
$ch = curl_init($url);
$headers = ['Accept: application/json'];
if ($payload !== null) {
$body = json_encode($payload);
if ($body === false) {
return ['ok' => false, 'status' => 500, 'error' => 'JSON encode failed'];
}
$headers[] = 'Content-Type: application/json';
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => max(1, $timeoutSeconds),
CURLOPT_CONNECTTIMEOUT => min(5, max(1, $timeoutSeconds)),
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_HTTPHEADER => $headers,
]);
$response = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = (string)curl_error($ch);
curl_close($ch);
if ($response === false) {
return ['ok' => false, 'status' => $status, 'error' => ($error !== '' ? $error : 'curl failed')];
}
$decoded = json_decode((string)$response, true);
return [
'ok' => $status >= 200 && $status < 400,
'status' => $status,
'json' => is_array($decoded) ? $decoded : null,
'raw' => (string)$response,
'error' => ($status >= 400 ? ('HTTP ' . $status) : ''),
];
}
function speaches_http_bytes(string $url, array $payload, int $timeoutSeconds = 300): array
{
$body = json_encode($payload);
if ($body === false) {
return ['ok' => false, 'status' => 500, 'error' => 'JSON encode failed'];
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => max(10, $timeoutSeconds),
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
'Accept: audio/*,application/octet-stream',
'Content-Type: application/json',
],
]);
$response = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = (string)curl_error($ch);
curl_close($ch);
if ($response === false) {
return ['ok' => false, 'status' => $status, 'error' => ($error !== '' ? $error : 'curl failed')];
}
if ($status >= 400) {
return ['ok' => false, 'status' => $status, 'error' => 'HTTP ' . $status, 'raw' => (string)$response];
}
return ['ok' => true, 'status' => $status, 'bytes' => (string)$response];
}
function speaches_normalize_response_format(string $format): string
{
$value = strtolower(trim($format));
if (!in_array($value, ['wav', 'mp3', 'flac', 'pcm'], true)) {
return 'wav';
}
return $value;
}
function speaches_fetch_models_and_voices(array $config): array
{
$ready = speaches_service_ensure_ready($config);
if (!($ready['ok'] ?? false)) {
return ['ok' => false, 'http_status' => 503, 'message' => (string)($ready['message'] ?? 'Speaches nicht bereit')];
}
register_shutdown_function('speaches_service_schedule_auto_stop', $config);
$models = speaches_http_json('GET', rtrim((string)$config['remote_endpoint'], '/') . '/v1/models', null, 30);
$voices = speaches_http_json('GET', rtrim((string)$config['remote_endpoint'], '/') . '/v1/audio/voices', null, 30);
return [
'ok' => ($models['ok'] ?? false) === true,
'http_status' => (int)($models['status'] ?? 200),
'models' => $models['json']['data'] ?? $models['json']['models'] ?? [],
'voices' => $voices['json']['voices'] ?? [],
'message' => ($models['ok'] ?? false) ? 'loaded' : ((string)($models['error'] ?? 'models request failed')),
];
}
function speaches_ensure_model_installed(array $config, string $modelId): array
{
$base = rtrim((string)$config['remote_endpoint'], '/');
$current = speaches_http_json('GET', $base . '/v1/models/' . rawurlencode($modelId), null, 30);
if (($current['ok'] ?? false) === true) {
return ['ok' => true, 'downloaded' => false];
}
$download = speaches_http_json('POST', $base . '/v1/models/' . rawurlencode($modelId), [], 300);
if (($download['ok'] ?? false) === true) {
return ['ok' => true, 'downloaded' => true];
}
return ['ok' => false, 'message' => (string)($download['error'] ?? 'model download failed')];
}
function speaches_build_output_target(array $config, string $basename, string $format): array
{
$safeBase = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $basename) ?: ('speaches-' . date('His'));
$dateDir = date('Y-m-d');
$root = rtrim((string)$config['output_dir'], '/');
$dir = $root . '/' . $dateDir;
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
return ['ok' => false, 'message' => 'output dir could not be created'];
}
$filename = $safeBase . '-' . date('His') . '.' . $format;
$path = $dir . '/' . $filename;
$url = rtrim((string)$config['public_base_url'], '/') . rtrim((string)$config['output_url_base'], '/') . '/' . $dateDir . '/' . $filename;
return ['ok' => true, 'path' => $path, 'url' => $url];
}
function speaches_render_to_file(array $config, array $request): array
{
$config = speaches_config($config);
$ready = speaches_service_ensure_ready($config);
if (!($ready['ok'] ?? false)) {
return ['ok' => false, 'http_status' => 503, 'message' => (string)($ready['message'] ?? 'Speaches nicht bereit')];
}
register_shutdown_function('speaches_service_schedule_auto_stop', $config);
$input = trim((string)($request['input'] ?? ''));
if ($input === '') {
return ['ok' => false, 'http_status' => 400, 'message' => 'input is required'];
}
$inputLength = function_exists('mb_strlen') ? mb_strlen($input) : strlen($input);
if ($inputLength > (int)$config['max_text_chars']) {
return ['ok' => false, 'http_status' => 400, 'message' => 'input exceeds max_text_chars'];
}
$model = trim((string)($request['model'] ?? $config['default_model'])) ?: (string)$config['default_model'];
$voice = trim((string)($request['voice'] ?? $config['default_voice'])) ?: (string)$config['default_voice'];
$speed = (float)($request['speed'] ?? 1.0);
$speed = max(0.5, min(2.0, $speed));
$format = speaches_normalize_response_format((string)($request['response_format'] ?? $config['default_response_format']));
$basename = trim((string)($request['output_basename'] ?? ('speaches-' . date('His'))));
$modelReady = speaches_ensure_model_installed($config, $model);
if (!($modelReady['ok'] ?? false)) {
return ['ok' => false, 'http_status' => 502, 'message' => (string)($modelReady['message'] ?? 'model install failed')];
}
$audio = speaches_http_bytes(
rtrim((string)$config['remote_endpoint'], '/') . '/v1/audio/speech',
[
'input' => $input,
'model' => $model,
'voice' => $voice,
'speed' => $speed,
'response_format' => $format,
],
300
);
if (!($audio['ok'] ?? false)) {
return ['ok' => false, 'http_status' => (int)($audio['status'] ?? 502), 'message' => (string)($audio['error'] ?? 'speech request failed')];
}
$target = speaches_build_output_target($config, $basename, $format);
if (!($target['ok'] ?? false)) {
return ['ok' => false, 'http_status' => 500, 'message' => (string)($target['message'] ?? 'output target failed')];
}
if (@file_put_contents((string)$target['path'], (string)$audio['bytes']) === false) {
return ['ok' => false, 'http_status' => 500, 'message' => 'audio file could not be written'];
}
return [
'ok' => true,
'http_status' => 200,
'audio_path' => (string)$target['path'],
'audio_url' => (string)$target['url'],
'model' => $model,
'voice' => $voice,
'response_format' => $format,
'bytes' => strlen((string)$audio['bytes']),
];
}