|null Cached model scan result. */ private ?array $cached_models = null; /** * Constructor. * * @param Logger $logger Logger instance. */ public function __construct( Logger $logger ) { $this->logger = $logger; } /** * Generate speech from text. * * Auto-detects whether the Piper binary supports --output-raw * (native CLI — raw PCM on stdout) or --output_file (Python wheel * wrapper — writes a WAV file to disk). * * @param string $text Text to synthesise. * @param string $model Absolute path to the .onnx model file. * @param string $quality Quality tier (low, medium, high). * @return string|null WAV data or null on failure. */ public function synthesise( string $text, string $model, string $quality = 'medium', string $sentence_silence = '', string $length_scale_override = '' ): ?string { $binary = $this->get_binary(); if ( '' === $binary ) { $this->logger->error( 'Piper binary not configured.' ); return null; } if ( ! $this->is_path_accessible( $binary ) ) { $this->logger->error( 'Piper binary outside open_basedir: {path}', [ 'path' => $binary ] ); return null; } if ( ! @file_exists( $binary ) || ! @is_executable( $binary ) ) { $this->logger->error( 'Piper binary not found or not executable: {path}', [ 'path' => $binary ] ); return null; } if ( ! @file_exists( $model ) ) { $this->logger->error( 'Piper model file not found: {model}', [ 'model' => $model ] ); return null; } // Prevent the PHP worker from being killed by max_execution_time // while Piper processes a long text. Restored in finally block. // Read timeout from settings (default 300s), clamped 30–3600. $settings = get_option( 'piperless_settings', [] ); $timeout = max( 30, min( 3600, (int) ( $settings['piper_process_timeout'] ?? 300 ) ) ); $prev_limit = (int) ini_get( 'max_execution_time' ); try { if ( $prev_limit > 0 && $prev_limit < $timeout ) { set_time_limit( $timeout ); } $mode = $this->detect_output_mode(); return match ( $mode ) { 'file' => $this->synthesise_via_file( $text, $binary, $model, $quality, $sentence_silence, $length_scale_override ), 'positional' => $this->synthesise_via_positional( $text, $binary, $model, $quality ), default => $this->synthesise_via_raw( $text, $binary, $model, $quality, $sentence_silence, $length_scale_override ), }; } finally { if ( $prev_limit > 0 ) { set_time_limit( $prev_limit ); } } } /** * Synthesise via --output-raw (raw PCM on stdout, wrap in WAV). * * @param string $text Text. * @param string $binary Piper executable path. * @param string $model Model path. * @param string $quality Quality tier. * @return string|null WAV data. */ private function synthesise_via_raw( string $text, string $binary, string $model, string $quality, string $sentence_silence = '', string $length_scale_override = '' ): ?string { $extra = $this->extra_flags( $quality, $sentence_silence, $length_scale_override ); $cmd = sprintf( '%s --model %s%s --output-raw', escapeshellarg( $binary ), escapeshellarg( $model ), $extra ); $this->logger->debug( 'Running (raw mode): {cmd}', [ 'cmd' => $cmd ] ); $process = proc_open( $cmd, [ 0 => [ 'pipe', 'r' ], 1 => [ 'pipe', 'w' ], 2 => [ 'pipe', 'w' ], ], $pipes ); if ( ! is_resource( $process ) ) { $this->logger->error( 'Failed to start Piper process (raw mode).' ); return null; } fwrite( $pipes[0], $text ); fclose( $pipes[0] ); // Stream raw PCM to a temp file instead of loading into memory. // Long texts can produce 25+ MB of audio — keeping it on disk // avoids exhausting PHP's memory limit. $temp_raw = tempnam( sys_get_temp_dir(), 'piperless_raw_' ); if ( false === $temp_raw ) { fclose( $pipes[1] ); fclose( $pipes[2] ); proc_close( $process ); $this->logger->error( 'Failed to create temp file for raw PCM.' ); return null; } $fp_out = fopen( $temp_raw, 'wb' ); if ( false === $fp_out ) { fclose( $pipes[1] ); fclose( $pipes[2] ); proc_close( $process ); @unlink( $temp_raw ); $this->logger->error( 'Failed to open temp file for raw PCM.' ); return null; } while ( ! feof( $pipes[1] ) ) { $chunk = fread( $pipes[1], 65536 ); if ( false === $chunk ) { break; } fwrite( $fp_out, $chunk ); } fclose( $fp_out ); fclose( $pipes[1] ); $stderr = stream_get_contents( $pipes[2] ); fclose( $pipes[2] ); $exit_code = proc_close( $process ); $raw_size = filesize( $temp_raw ); if ( 0 !== $exit_code || 0 === $raw_size ) { @unlink( $temp_raw ); $this->logger->error( 'Piper (raw mode) exited with code {code}: {stderr}', [ 'code' => $exit_code, 'stderr' => $this->strip_alsa_noise( $stderr ) ] ); return null; } $sample_rate = $this->get_model_sample_rate( $model ); $wav = $this->raw_file_to_wav( $temp_raw, $sample_rate ); @unlink( $temp_raw ); return $wav; } /** * Synthesise via --output_file (Piper writes WAV to a temp file). * * Used by Python wheel wrappers and installs where stdout piping * is not the default interface. * * @param string $text Text. * @param string $binary Piper executable path. * @param string $model Model path. * @param string $quality Quality tier. * @return string|null WAV data. */ private function synthesise_via_file( string $text, string $binary, string $model, string $quality, string $sentence_silence = '', string $length_scale_override = '' ): ?string { $temp_file = tempnam( sys_get_temp_dir(), 'piperless_' ) . '.wav'; $extra = $this->extra_flags( $quality, $sentence_silence, $length_scale_override ); $cmd = sprintf( '%s --model %s%s --output_file %s', escapeshellarg( $binary ), escapeshellarg( $model ), $extra, escapeshellarg( $temp_file ) ); $this->logger->debug( 'Running (file mode): {cmd}', [ 'cmd' => $cmd ] ); $process = proc_open( $cmd, [ 0 => [ 'pipe', 'r' ], 1 => [ 'pipe', 'w' ], 2 => [ 'pipe', 'w' ], ], $pipes ); if ( ! is_resource( $process ) ) { $this->logger->error( 'Failed to start Piper process (file mode).' ); @unlink( $temp_file ); return null; } fwrite( $pipes[0], $text ); fclose( $pipes[0] ); // Drain stdout / stderr so the process doesn't block. stream_get_contents( $pipes[1] ); fclose( $pipes[1] ); $stderr = stream_get_contents( $pipes[2] ); fclose( $pipes[2] ); $exit_code = proc_close( $process ); if ( 0 !== $exit_code || ! file_exists( $temp_file ) || 0 === filesize( $temp_file ) ) { $this->logger->error( 'Piper (file mode) exited with code {code}: {stderr}', [ 'code' => $exit_code, 'stderr' => $this->strip_alsa_noise( $stderr ) ] ); @unlink( $temp_file ); return null; } $wav = @file_get_contents( $temp_file ); @unlink( $temp_file ); return ( false === $wav || '' === $wav ) ? null : $wav; } /** * Synthesise via positional wrapper (binary model_path "text"). * * For custom wrappers that take the model path and text as positional * arguments and always write to `output.wav` in the current directory. * Runs inside a temp directory so the output file is isolated. * * @param string $text Text. * @param string $binary Piper executable path. * @param string $model Model path. * @param string $quality Quality tier (ignored — wrapper controls quality via model choice). * @return string|null WAV data. */ private function synthesise_via_positional( string $text, string $binary, string $model, string $quality ): ?string { // Create a temp directory as CWD so output.wav lands cleanly. $work_dir = sys_get_temp_dir() . '/piperless_' . uniqid(); error_clear_last(); if ( ! @mkdir( $work_dir, 0755, true ) ) { $this->logger->log_last_error( 'Create positional temp dir' ); $this->logger->error( 'Failed to create temp work directory.' ); return null; } $cmd = sprintf( '%s %s %s', escapeshellarg( $binary ), escapeshellarg( $model ), escapeshellarg( $text ) ); $this->logger->debug( 'Running (positional mode): {cmd}', [ 'cmd' => $cmd ] ); $descriptorspec = [ 0 => [ 'pipe', 'r' ], 1 => [ 'pipe', 'w' ], 2 => [ 'pipe', 'w' ], ]; $process = proc_open( $cmd, $descriptorspec, $pipes, $work_dir ); if ( ! is_resource( $process ) ) { $this->logger->error( 'Failed to start Piper process (positional mode).' ); $this->rmdir_recursive( $work_dir ); return null; } // Close stdin immediately — text is in the command args. fclose( $pipes[0] ); $stdout = stream_get_contents( $pipes[1] ); fclose( $pipes[1] ); $stderr = stream_get_contents( $pipes[2] ); fclose( $pipes[2] ); $exit_code = proc_close( $process ); $output_file = $work_dir . '/output.wav'; // WAV file missing or empty → definite failure. if ( ! file_exists( $output_file ) || 0 === filesize( $output_file ) ) { $this->logger->error( 'Piper (positional mode) produced no output: {stderr}', [ 'stderr' => $this->strip_alsa_noise( $stderr . "\n" . $stdout ) ] ); $this->rmdir_recursive( $work_dir ); return null; } // WAV exists, but wrapper exited non-zero (e.g. aplay failure on headless // server). Log a warning and return the audio anyway. if ( 0 !== $exit_code ) { $noise_free = $this->strip_alsa_noise( $stderr . "\n" . $stdout ); if ( '' !== $noise_free ) { $this->logger->warning( 'Piper (positional mode) exited {code} but output.wav was produced: {stderr}', [ 'code' => $exit_code, 'stderr' => $noise_free ] ); } } $wav = @file_get_contents( $output_file ); $this->rmdir_recursive( $work_dir ); return ( false === $wav || '' === $wav ) ? null : $wav; } /** * Validate a model's companion .json config file. * * Detects Git LFS pointer files (common on Hugging Face when * git-lfs is not installed) and empty/malformed JSON. * Logs a specific warning and returns false for broken configs. * * @param string $json_path Path to the .onnx.json file. * @param string $name Model basename for log messages. * @return bool True if the config is valid JSON. */ public function is_valid_model_config( string $json_path, string $name = '' ): bool { if ( ! file_exists( $json_path ) ) { // No .json is fine — native Piper reads config from the .onnx. // Only the Python wheel requires a separate .json. $this->logger->debug( 'Model {name} has no companion .json config — native Piper mode will still work.', [ 'name' => $name ] ); return true; // Allow — just warn at debug level. } $size = filesize( $json_path ); if ( 0 === $size ) { $this->logger->warning( 'Model {name} .json config is empty (0 bytes) — it will not work with the Python Piper wheel.', [ 'name' => $name ] ); return false; } // Read the first bytes to detect Git LFS pointer files. error_clear_last(); $head = @file_get_contents( $json_path, false, null, 0, 64 ); if ( false === $head ) { $this->logger->log_last_error( 'Read model JSON header for ' . $name ); $this->logger->warning( 'Model {name} .json config cannot be read — check file permissions.', [ 'name' => $name ] ); return false; } // Git LFS pointer detection (common on Hugging Face without git-lfs). if ( str_starts_with( ltrim( $head ), 'version https://git-lfs.github.com' ) ) { $this->logger->warning( 'Model {name} .json is a Git LFS pointer, not actual JSON. Install git-lfs and run "git lfs pull", or download the real file from Hugging Face.', [ 'name' => $name ] ); return false; } // Read full content for JSON validation. error_clear_last(); $json = @file_get_contents( $json_path ); if ( false === $json ) { $this->logger->log_last_error( 'Read model JSON for ' . $name ); $this->logger->warning( 'Model {name} .json config cannot be read — check file permissions.', [ 'name' => $name ] ); return false; } json_decode( $json, true ); if ( JSON_ERROR_NONE !== json_last_error() ) { // Show the first 120 chars of the file to help diagnose the issue. $preview = mb_substr( $json, 0, 120 ); $this->logger->warning( 'Model {name} .json config is not valid JSON ({error}). First bytes: {preview}', [ 'name' => $name, 'error' => json_last_error_msg(), 'preview' => $preview ] ); return false; } return true; } /** * Strip ALSA/aplay noise from stderr/stdout output. * * @param string $output Raw process output. * @return string Cleaned output. */ private function strip_alsa_noise( string $output ): string { $lines = explode( "\n", $output ); $kept = []; foreach ( $lines as $line ) { $trimmed = trim( $line ); if ( '' === $trimmed ) { continue; } // Skip ALSA library chatter. if ( str_starts_with( $trimmed, 'ALSA lib' ) ) { continue; } // Skip aplay error lines. if ( str_starts_with( $trimmed, 'aplay:' ) ) { continue; } $kept[] = $line; } return implode( "\n", $kept ); } /** * Recursively delete a directory. * * @param string $dir Path to remove. */ private function rmdir_recursive( string $dir ): void { if ( ! is_dir( $dir ) ) { return; } $items = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $dir, \RecursiveDirectoryIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ( $items as $item ) { if ( $item->isDir() ) { @rmdir( $item->getRealPath() ); } else { @unlink( $item->getRealPath() ); } } @rmdir( $dir ); } /** * Detect whether the Piper binary supports --output-raw or --output_file. * * Runs `--help` once and caches the result. * * @return string 'raw' or 'file'. */ public function detect_output_mode(): string { if ( null !== $this->output_mode ) { return $this->output_mode; } // Honour explicit user override. $settings = get_option( 'piperless_settings', [] ); $interface = $settings['piper_interface'] ?? 'auto'; if ( 'standard' === $interface ) { $this->output_mode = 'raw'; return $this->output_mode; } if ( 'positional' === $interface ) { $this->output_mode = 'positional'; return $this->output_mode; } // Auto-detect from --help output. $binary = $this->get_binary(); if ( '' === $binary || ! file_exists( $binary ) ) { $this->output_mode = 'file'; // Safe default for unknown wrappers. return $this->output_mode; } $help = $this->run_help(); $help_text = implode( "\n", $help['output'] ); if ( false !== strpos( $help_text, '--output-raw' ) ) { $this->output_mode = 'raw'; } elseif ( false !== strpos( $help_text, '--output_file' ) || false !== strpos( $help_text, '--output-file' ) ) { $this->output_mode = 'file'; } else { // Unknown wrapper — check for positional-arg signature // (help text with placeholders and no --flags). if ( str_contains( $help_text, '<' ) && str_contains( $help_text, '>' ) && ! preg_match( '/--\w+/', $help_text ) ) { $this->output_mode = 'positional'; } else { $this->output_mode = 'file'; } } $this->logger->info( 'Detected Piper output mode: {mode} for {binary}', [ 'mode' => $this->output_mode, 'binary' => $binary ] ); return $this->output_mode; } /** * Run --help and return output + exit code (cached once). * * @return array{output:array,exit_code:int} */ private function run_help(): array { static $cached = null; if ( null !== $cached ) { return $cached; } $binary = $this->get_binary(); $output = []; $ret = 0; if ( '' !== $binary && file_exists( $binary ) ) { $safe_binary = escapeshellarg( $binary ); exec( $safe_binary . ' --help 2>&1', $output, $ret ); } $cached = [ 'output' => $output, 'exit_code' => $ret ]; return $cached; } /** * Check whether a filesystem path is accessible given PHP's open_basedir. * * @param string $path Absolute path to check. * @return bool */ private function is_path_accessible( string $path ): bool { $open_basedir = ini_get( 'open_basedir' ); if ( empty( $open_basedir ) ) { return true; // No restriction. } $real_path = @realpath( $path ); if ( false === $real_path ) { // Path doesn't exist yet — check parent directory. // Stop at filesystem root to avoid triggering open_basedir // warnings on file_exists('/'). $parent = dirname( $path ); while ( '' !== $parent && '/' !== $parent && ! @file_exists( $parent ) ) { $parent = dirname( $parent ); } $resolved = ( '' !== $parent ) ? @realpath( $parent ) : false; $real_path = ( false !== $resolved ) ? $resolved : $path; } $allowed_dirs = explode( PATH_SEPARATOR, $open_basedir ); foreach ( $allowed_dirs as $allowed ) { $allowed = rtrim( trim( $allowed ), '/' ); if ( '' !== $allowed && str_starts_with( $real_path, $allowed . '/' ) ) { return true; } if ( $real_path === $allowed ) { return true; } } return false; } /** * Get a human-readable description of the open_basedir restriction. * * @return string */ public function open_basedir_info(): string { $open_basedir = ini_get( 'open_basedir' ); return ( '' === $open_basedir ) ? __( 'No restriction (open_basedir is not set).', 'piperless' ) : sprintf( /* translators: %s: open_basedir paths */ __( 'Restricted to: %s', 'piperless' ), $open_basedir ); } /** * Test the Piper binary and return version info. * * Tolerates wrappers that don't support --help — a binary that * exists and is executable is reported OK even if --help fails. * * @return array{ok:bool,version:string|null,error:string|null,mode:string} */ public function test(): array { $binary = $this->get_binary(); if ( '' === $binary ) { return [ 'ok' => false, 'version' => null, 'error' => __( 'Piper binary path is not configured.', 'piperless' ), 'open_basedir' => $this->open_basedir_info(), 'mode' => null, ]; } if ( ! $this->is_path_accessible( $binary ) ) { return [ 'ok' => false, 'version' => null, 'error' => sprintf( /* translators: %1$s: binary path, %2$s: open_basedir */ __( 'Piper binary path %1$s is outside PHP open_basedir (%2$s). Add /opt to open_basedir in your hosting config.', 'piperless' ), $binary, ini_get( 'open_basedir' ) ), 'open_basedir' => $this->open_basedir_info(), 'mode' => null, ]; } if ( ! file_exists( $binary ) ) { return [ 'ok' => false, 'version' => null, 'error' => sprintf( /* translators: %s: file path that was checked */ __( 'Piper binary not found at the configured path: %s', 'piperless' ), $binary ), 'open_basedir' => $this->open_basedir_info(), 'mode' => null, ]; } if ( ! is_executable( $binary ) ) { return [ 'ok' => false, 'version' => null, 'error' => __( 'Piper binary is not executable.', 'piperless' ), 'open_basedir' => $this->open_basedir_info(), 'mode' => null, ]; } $help = $this->run_help(); $output = $help['output']; $help_ok = ( 0 === $help['exit_code'] ); $mode = $this->detect_output_mode(); // Try to extract version from help output. $version = null; if ( $help_ok ) { foreach ( $output as $line ) { if ( preg_match( '/(\d+\.\d+\.\d+)/', $line, $m ) ) { $version = $m[1]; break; } } } // Binary exists and is executable — that's enough for OK. return [ 'ok' => true, 'version' => $version, 'error' => $help_ok ? null : __( 'Binary found, but --help is not supported. Version unknown — synthesis may still work.', 'piperless' ), 'mode' => $mode, ]; } /** * Scan the models directory and return discovered models. * * Each model entry includes voice, language, quality, and path. * * @return array */ public function scan_models(): array { // Return cached result to avoid re-scanning the directory multiple // times per request (admin page calls this 3×). if ( null !== $this->cached_models ) { return $this->cached_models; } $settings = get_option( 'piperless_settings', [] ); $dir = $settings['models_directory'] ?? ''; // Skip filesystem access if the directory is outside open_basedir — // avoids hangs on stale NFS mounts or slow network filesystems. if ( '' === $dir ) { $this->cached_models = []; return []; } if ( ! $this->is_path_accessible( $dir ) ) { $this->logger->warning( 'Models directory {dir} is outside open_basedir ({ob}).', [ 'dir' => $dir, 'ob' => ini_get( 'open_basedir' ) ] ); $this->cached_models = []; return []; } if ( ! @is_dir( $dir ) ) { $this->logger->debug( 'Models directory not accessible: {dir}', [ 'dir' => $dir ] ); $this->cached_models = []; return []; } // Recursive scan for .onnx files. $files = []; try { $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ) ); foreach ( $iterator as $file_info ) { if ( ! $file_info->isFile() ) { continue; } $ext = pathinfo( $file_info->getPathname(), PATHINFO_EXTENSION ); if ( 'onnx' === strtolower( $ext ) ) { $files[] = $file_info->getPathname(); } } } catch ( \Exception $e ) { $this->logger->error( 'Error scanning models directory {dir}: {msg}', [ 'dir' => $dir, 'msg' => $e->getMessage() ] ); $this->cached_models = []; return []; } $this->logger->debug( 'Found {count} .onnx file(s) in {dir}', [ 'count' => count( $files ), 'dir' => $dir ] ); if ( [] === $files ) { $this->cached_models = []; return []; } $models = []; foreach ( $files as $file ) { $basename = basename( $file, '.onnx' ); // Expected format: {language}_{voice}-{quality} (e.g. en_US-amy-low) // Tolerate variations with fewer segments. $parts = explode( '-', $basename ); $quality = 'medium'; if ( count( $parts ) >= 2 ) { $quality_candidate = strtolower( end( $parts ) ); $known_qualities = apply_filters( 'piperless_quality_tiers', [ 'low', 'medium', 'high', 'lite', 'small', 'fast', 'quality' ] ); if ( in_array( $quality_candidate, $known_qualities, true ) ) { $quality = $quality_candidate; array_pop( $parts ); } } // After removing quality, remaining parts are: // ['en_US', 'lessac'] → language=first, voice=rest // ['en_US', 'lessac', 'low'] → (if quality not matched) $language = 'en_US'; $voice = 'default'; if ( count( $parts ) >= 1 ) { $lang_part = array_shift( $parts ); $lang_segs = explode( '_', $lang_part ); if ( count( $lang_segs ) >= 2 ) { $language = $lang_segs[0] . '_' . $lang_segs[1]; } else { $language = $lang_part; } } if ( count( $parts ) >= 1 ) { $voice = implode( '-', $parts ); } // Validate the companion .json — skip models with broken configs. $json_path = $file . '.json'; $json_valid = $this->is_valid_model_config( $json_path, $basename ); if ( ! $json_valid ) { continue; // Skip this model — logged inside is_valid_model_config. } $models[] = [ 'name' => $basename, 'voice' => $voice, 'language' => $language, 'quality' => $quality, 'path' => $file, ]; } $this->cached_models = $models; return $models; } /** * Get the model file for a given voice, language, and quality. * * @param string $voice Voice name. * @param string $language Language code. * @param string $quality Quality tier. * @return string|null Absolute path or null if not found. */ public function find_model( string $voice, string $language, string $quality ): ?string { $models = $this->scan_models(); // Exact match first. foreach ( $models as $model ) { if ( $model['voice'] === $voice && $model['language'] === $language && $model['quality'] === $quality ) { return $model['path']; } } // Fuzzy: match voice + language, any quality. foreach ( $models as $model ) { if ( $model['voice'] === $voice && $model['language'] === $language ) { return $model['path']; } } // Fuzzy: match language, any voice/quality. foreach ( $models as $model ) { if ( $model['language'] === $language ) { return $model['path']; } } // Return first available model as fallback. if ( [] !== $models ) { return $models[0]['path']; } return null; } /** * Build extra CLI flags from advanced settings. * * @param string $quality Quality tier (for default length_scale). * @return string Space-prefixed flags or empty string. */ private function extra_flags( string $quality, string $sentence_silence = '', string $length_scale_override = '' ): string { $settings = get_option( 'piperless_settings', [] ); $flags = ''; // Sentence silence — per-post override wins over global setting. $silence = ( '' !== $sentence_silence ) ? $sentence_silence : ( $settings['piper_sentence_silence'] ?? '' ); if ( '' !== $silence && is_numeric( $silence ) ) { $flags .= ' --sentence_silence ' . escapeshellarg( $silence ); } // Length scale — per-post > global override > quality-based default. if ( '' !== $length_scale_override && is_numeric( $length_scale_override ) ) { $flags .= ' --length-scale ' . escapeshellarg( $length_scale_override ); } else { $scale = $settings['piper_length_scale'] ?? ''; if ( '' !== $scale && is_numeric( $scale ) ) { $flags .= ' --length-scale ' . escapeshellarg( $scale ); } else { $flags .= ' --length-scale ' . escapeshellarg( (string) $this->length_scale( $quality ) ); } } return $flags; } /** * Get the configured Piper binary path. * * @return string */ private function get_binary(): string { $settings = get_option( 'piperless_settings', [] ); return $settings['piper_binary'] ?? ''; } /** * Map quality string to Piper's --length-scale value. * * Lower = faster/lower quality, higher = slower/higher quality. * * @param string $quality low|medium|high. * @return float */ private function length_scale( string $quality ): float { return match ( $quality ) { 'low' => 0.6, 'high' => 1.2, default => 1.0, // medium }; } /** * Try to determine sample rate from the model's companion .json file. * * @param string $model_path Path to .onnx model. * @return int Sample rate (default 22050). */ private function get_model_sample_rate( string $model_path ): int { $json_path = $model_path . '.json'; if ( ! file_exists( $json_path ) ) { return 22050; } $json = @file_get_contents( $json_path ); if ( false === $json ) { return 22050; } $data = json_decode( $json, true ); if ( ! is_array( $data ) ) { return 22050; } // Piper model JSON typically has "audio" → "sample_rate". return (int) ( $data['audio']['sample_rate'] ?? $data['sample_rate'] ?? 22050 ); } /** * Wrap a raw PCM temp file in a WAV container. * * Reads the raw PCM from disk only once — avoids keeping 25+ MB * in memory alongside the WAV output during synthesis. * * @param string $raw_path Path to raw PCM temp file. * @param int $sample_rate Sample rate in Hz. * @return string|null WAV file data, or null if file is empty. */ private function raw_file_to_wav( string $raw_path, int $sample_rate = 22050 ): ?string { $raw_size = filesize( $raw_path ); if ( 0 === $raw_size ) { return null; } $num_channels = 1; $bits_per_sample = 16; $byte_rate = $sample_rate * $num_channels * ( $bits_per_sample / 8 ); $block_align = $num_channels * ( $bits_per_sample / 8 ); $header = pack( 'A4VA4A4VvvVVvvA4V', 'RIFF', 36 + $raw_size, 'WAVE', 'fmt ', 16, 1, // PCM $num_channels, $sample_rate, $byte_rate, $block_align, $bits_per_sample, 'data', $raw_size ); $raw = @file_get_contents( $raw_path ); return ( false === $raw ) ? null : ( $header . $raw ); } /** * Wrap raw 16-bit mono PCM in a WAV container. * * @param string $raw Raw PCM audio data. * @param int $sample_rate Sample rate in Hz. * @return string WAV file data. */ private function raw_to_wav( string $raw, int $sample_rate = 22050 ): string { $num_channels = 1; $bits_per_sample = 16; $byte_rate = $sample_rate * $num_channels * ( $bits_per_sample / 8 ); $block_align = $num_channels * ( $bits_per_sample / 8 ); $data_size = strlen( $raw ); $header = pack( 'A4VA4A4VvvVVvvA4V', 'RIFF', 36 + $data_size, 'WAVE', 'fmt ', 16, // PCM 1, // format = PCM $num_channels, $sample_rate, $byte_rate, $block_align, $bits_per_sample, 'data', $data_size ); return $header . $raw; } }