) prevents * two requests from running Piper simultaneously for identical content. * The lock is released on all exit paths (success, synthesis failure, * cache-write failure). A 5-minute TTL prevents permanent deadlocks. * * ## Text extraction * * When skip_embedded_content is enabled and no manual excerpt exists, * the post body is parsed as Gutenberg blocks. Embed-type blocks * (core/embed, core-embed/*, extensible via piperless_skip_blocks) are * stripped before text is collected from innerHTML recursively. * * @since 0.1.0 */ class Transcriber { /** @var Logger */ private Logger $logger; /** @var Piper */ private Piper $piper; /** @var Cache_Manager */ private Cache_Manager $cache; /** * Constructor. * * @param Logger $logger Logger instance. * @param Piper $piper Piper TTS wrapper. * @param Cache_Manager $cache Cache manager. */ public function __construct( Logger $logger, Piper $piper, Cache_Manager $cache ) { $this->logger = $logger; $this->piper = $piper; $this->cache = $cache; } /** * Generate (or retrieve) an audio transcript for a post. * * @param int $post_id WordPress post ID. * @param string $voice Voice name override, or '' for default. * @param string $language Language code override, or '' for default. * @param string $quality Quality override, or '' for default. * @return array{success:bool,url:string|null,error:string|null} */ public function generate( int $post_id, string $voice = '', string $language = '', string $quality = '' ): array { $post = get_post( $post_id ); if ( ! $post ) { return $this->error( __( 'Post not found.', 'piperless' ) ); } if ( ! in_array( $post->post_type, $this->supported_post_types(), true ) ) { return $this->error( __( 'Unsupported post type.', 'piperless' ) ); } // Resolve parameters — per-post meta > REST params > settings defaults. $settings = get_option( 'piperless_settings', [] ); $post_voice = get_post_meta( $post_id, '_piperless_voice', true ); $post_language = get_post_meta( $post_id, '_piperless_language', true ); $post_quality = get_post_meta( $post_id, '_piperless_quality', true ); $voice = ( '' !== $voice ) ? $voice : ( ( ! empty( $post_voice ) ) ? $post_voice : ( $settings['default_voice'] ?? '' ) ); $language = ( '' !== $language ) ? $language : ( ( ! empty( $post_language ) ) ? $post_language : ( $settings['default_language'] ?? 'en_US' ) ); $quality = ( '' !== $quality ) ? $quality : ( ( ! empty( $post_quality ) ) ? $post_quality : ( $settings['default_quality'] ?? 'medium' ) ); $this->logger->debug( 'Generate: voice={voice} lang={lang} quality={quality} for post {id}', [ 'voice' => $voice, 'lang' => $language, 'quality' => $quality, 'id' => $post_id, ] ); // Per-post Piper overrides. $post_silence = get_post_meta( $post_id, '_piperless_sentence_silence', true ); $post_length_scale = get_post_meta( $post_id, '_piperless_length_scale', true ); // Find the model. $model = $this->piper->find_model( $voice, $language, $quality ); if ( null === $model ) { return $this->error( __( 'No suitable Piper voice model found. Check your models directory.', 'piperless' ) ); } // Extract text — prefer excerpt, fallback to content. $text = $this->extract_text( $post, $settings ); if ( '' === trim( $text ) ) { return $this->error( __( 'No text content available for this post.', 'piperless' ) ); } // Cache key (includes bitrate and format so changing them regenerates files). $settings_cache = get_option( 'piperless_settings', [] ); $bitrate = $settings_cache['piper_mp3_bitrate'] ?? '32k'; $audio_format = $settings_cache['piper_audio_format'] ?? 'mp3'; $cache_key = $this->cache->cache_key( $text, $model, $language, $quality, $bitrate, $audio_format ); // Store cache key in post meta before generation so it is tracked. $model_basename = basename( $model, '.onnx' ); $this->store_cache_key( $post_id, $cache_key, $model_basename ); // Return cached audio if available. if ( $this->cache->exists( $cache_key ) ) { $this->update_post_meta( $post_id, $cache_key, basename( $model, '.onnx' ) ); $this->logger->info( 'Using cached audio for post {id}', [ 'id' => $post_id ] ); return [ 'success' => true, 'url' => $this->cache->proxy_url( $cache_key ), 'error' => null, ]; } // Ensure cache directory. if ( ! $this->cache->ensure_dir() ) { return $this->error( __( 'Failed to create cache directory.', 'piperless' ) ); } // Concurrency mutex: only one synthesis per cache key at a time. // Prevents parallel Piper processes from exhausting server resources. $mutex_key = 'piperless_synthesising_' . $cache_key; if ( false !== get_transient( $mutex_key ) ) { return $this->error( __( 'Audio generation already in progress for this content. Please wait.', 'piperless' ) ); } set_transient( $mutex_key, 1, 300 ); // 5-minute TTL as safety net. // Generate audio. $this->logger->info( 'Generating audio for post {id} with model {model}', [ 'id' => $post_id, 'model' => basename( $model ), ] ); $wav = $this->piper->synthesise( $text, $model, $quality, $post_silence, $post_length_scale ); if ( null === $wav ) { delete_transient( $mutex_key ); return $this->error( __( 'Piper failed to generate audio. Check the logs for details.', 'piperless' ) ); } // Store in cache — release mutex on write failure. if ( ! $this->cache->put( $cache_key, $wav ) ) { delete_transient( $mutex_key ); return $this->error( __( 'Failed to write audio file to cache.', 'piperless' ) ); } // Update post meta (MP3 conversion happens inside cache->put()). $this->update_post_meta( $post_id, $cache_key, basename( $model, '.onnx' ) ); // Release mutex on success. delete_transient( $mutex_key ); $this->logger->info( 'Audio generated for post {id}', [ 'id' => $post_id ] ); return [ 'success' => true, 'url' => $this->cache->proxy_url( $cache_key ), 'error' => null, ]; } /** * Get the audio status for a post. * * @param int $post_id Post ID. * @return array{has_audio:bool,url:string|null,duration:float|null,cache_key:string|null} */ public function status( int $post_id ): array { $url = get_post_meta( $post_id, '_piperless_audio_url', true ); $duration = get_post_meta( $post_id, '_piperless_duration', true ); $cache_key = get_post_meta( $post_id, '_piperless_cache_key', true ); $format = get_post_meta( $post_id, '_piperless_audio_format', true ); return [ 'has_audio' => ! empty( $url ), 'url' => $url ?: null, 'duration' => $duration ? (float) $duration : null, 'cache_key' => $cache_key ?: null, 'format' => $format ?: 'mp3', ]; } /** * Extract text from a post. * * Priority: custom excerpt → filtered content → full content. * * When "Skip Embedded Content" is enabled and no manual excerpt exists, * the post body is parsed as Gutenberg blocks and any embed-type blocks * (core/embed, core-embed/*, plus blocks added via the piperless_skip_blocks * filter) are excluded before text extraction. * * @param \WP_Post $post Post object. * @param array $settings Plugin settings. * @return string */ private function extract_text( \WP_Post $post, array $settings ): string { $title = trim( $post->post_title ); // Check for manual excerpt first. $excerpt = $post->post_excerpt; if ( '' !== trim( $excerpt ) ) { $body = wp_strip_all_tags( $excerpt, true ); return ( '' !== $title ? $title . '. ' : '' ) . $body; } // When skip_embedded_content is enabled and the post has blocks, // strip embed-type blocks before rendering. $skip_embeds = ! empty( $settings['skip_embedded_content'] ); if ( $skip_embeds && has_blocks( $post->post_content ) ) { $blocks = parse_blocks( $post->post_content ); $blocks = $this->filter_embed_blocks( $blocks ); // Extract text directly from the block tree — avoids // render_block() + the_content double-processing which // can strip paragraph/heading content. $content = $this->extract_block_text( $blocks ); $content = wp_strip_all_tags( $content, true ); return ( '' !== $title ? $title . '. ' : '' ) . trim( $content ); } // Standard fallback: render everything. $content = get_the_content( null, false, $post ); $content = apply_filters( 'the_content', $content ); $content = wp_strip_all_tags( $content, true ); return ( '' !== $title ? $title . '. ' : '' ) . trim( $content ); } /** * Recursively extract text from a parsed block tree. * * Walks the block tree and concatenates innerHTML from every block, * recursing into innerBlocks. This avoids the render_block() + * the_content double-processing path which can lose content. * * @param array $blocks Parsed blocks. * @return string */ private function extract_block_text( array $blocks ): string { $text = ''; foreach ( $blocks as $block ) { // Collect the block's own inner HTML (e.g. the

content). $text .= $block['innerHTML'] ?? ''; // Recurse into inner blocks (columns, groups, etc.). if ( ! empty( $block['innerBlocks'] ) ) { $text .= $this->extract_block_text( $block['innerBlocks'] ); } } return $text; } /** * Recursively remove embedded content blocks from a parsed block list. * * Skipped patterns: core/embed, core-embed/* by default. * Extensible via the `piperless_skip_blocks` filter. * * @param array $blocks Parsed blocks from parse_blocks(). * @return array */ private function filter_embed_blocks( array $blocks ): array { $skip_patterns = apply_filters( 'piperless_skip_blocks', [ 'core/embed', 'core-embed/', ] ); $filtered = []; foreach ( $blocks as $block ) { $block_name = $block['blockName'] ?? ''; // Check skip patterns. $skip = false; foreach ( $skip_patterns as $pattern ) { if ( str_starts_with( $block_name, $pattern ) ) { $skip = true; break; } } if ( $skip ) { continue; } // Recurse into inner blocks (e.g. columns, groups). if ( ! empty( $block['innerBlocks'] ) ) { $block['innerBlocks'] = $this->filter_embed_blocks( $block['innerBlocks'] ); } $filtered[] = $block; } return $filtered; } /** * Store per-post cache key (appends if multiple keys exist). * * @param int $post_id Post ID. * @param string $cache_key Cache key. */ private function store_cache_key( int $post_id, string $cache_key, string $model_basename = '' ): void { $existing = get_post_meta( $post_id, '_piperless_cache_key', true ); if ( empty( $existing ) ) { update_post_meta( $post_id, '_piperless_cache_key', [ $cache_key => $model_basename ] ); return; } $keys = is_array( $existing ) ? $existing : [ $existing ]; if ( ! isset( $keys[ $cache_key ] ) ) { $keys[ $cache_key ] = $model_basename; update_post_meta( $post_id, '_piperless_cache_key', $keys ); } } /** * Update post meta after successful generation. * * @param int $post_id Post ID. * @param string $cache_key Cache key. * @param string $file_path Absolute WAV path. */ private function update_post_meta( int $post_id, string $cache_key, string $model_basename = '' ): void { $url = $this->cache->proxy_url( $cache_key ); $duration = $this->wav_duration( $cache_key ); update_post_meta( $post_id, '_piperless_audio_url', $url ); update_post_meta( $post_id, '_piperless_duration', $duration ); update_post_meta( $post_id, '_piperless_generated_at', current_time( 'mysql', true ) ); // Store the audio format for the sidebar preview player. $settings = get_option( 'piperless_settings', [] ); update_post_meta( $post_id, '_piperless_audio_format', $settings['piper_audio_format'] ?? 'mp3' ); if ( '' !== $model_basename ) { update_post_meta( $post_id, '_piperless_model_name', $model_basename ); } } /** * Parse WAV file header to determine duration in seconds. * * @param string $file_path Absolute path to WAV file. * @return float Duration in seconds. */ public function wav_duration( string $cache_key ): float { // Try compressed formats first via ffprobe. $settings = get_option( 'piperless_settings', [] ); $format = $settings['piper_audio_format'] ?? 'mp3'; $path = $this->cache->file_path( $cache_key, $format ); if ( file_exists( $path ) ) { $dur = $this->ffprobe_duration( $path ); if ( $dur > 0.0 ) { return $dur; } } // Fall back to WAV header reading. $file_path = $this->cache->file_path( $cache_key, 'mp3' ); if ( ! file_exists( $file_path ) ) { $file_path = $this->cache->file_path( $cache_key, 'opus' ); } if ( ! file_exists( $file_path ) ) { $file_path = $this->cache->dir() . '/' . $cache_key . '.wav'; } if ( ! file_exists( $file_path ) ) { return 0.0; } // If not WAV, try ffprobe. if ( ! str_ends_with( $file_path, '.wav' ) ) { $dur = $this->ffprobe_duration( $file_path ); if ( $dur > 0.0 ) { return $dur; } } $fp = @fopen( $file_path, 'rb' ); if ( false === $fp ) { return 0.0; } // Read RIFF header. $header = fread( $fp, 44 ); fclose( $fp ); if ( strlen( $header ) < 44 ) { return 0.0; } $unpacked = unpack( 'Vsample_rate/Vbyte_rate/vblock_align', substr( $header, 24, 12 ) ); if ( false === $unpacked ) { return 0.0; } $sample_rate = $unpacked['sample_rate']; $byte_rate = $unpacked['byte_rate']; if ( 0 === $byte_rate ) { return 0.0; } $data_size = filesize( $file_path ) - 44; if ( $data_size <= 0 ) { return 0.0; } return $data_size / $byte_rate; } /** * Post types that Piperless supports. * * @return string[] */ private function supported_post_types(): array { $types = apply_filters( 'piperless_post_types', [ 'post', 'page' ] ); return is_array( $types ) ? $types : [ 'post' ]; } /** * Build an error response. * * @param string $message Error message. * @return array{success:bool,url:null,error:string} */ private function error( string $message ): array { $this->logger->error( $message ); return [ 'success' => false, 'url' => null, 'error' => $message, ]; } /** * Get audio duration using ffprobe. * * @param string $file_path Absolute path to the audio file. * @return float Duration in seconds, or 0.0 on failure. */ private function ffprobe_duration( string $file_path ): float { $ffprobe = $this->find_ffprobe(); if ( null === $ffprobe ) { return 0.0; } $cmd = sprintf( '%s -v error -show_entries format=duration -of csv=p=0 %s 2>&1', escapeshellarg( $ffprobe ), escapeshellarg( $file_path ) ); $output = []; $ret = 0; exec( $cmd, $output, $ret ); if ( 0 !== $ret || empty( $output ) ) { return 0.0; } return (float) trim( $output[0] ); } /** * Find ffprobe binary on the system. * * @return string|null Absolute path, or null. */ private function find_ffprobe(): ?string { static $cached = null; static $resolved = null; if ( null !== $cached ) { return $resolved; } $cached = true; $settings = get_option( 'piperless_settings', [] ); $custom = $settings['piper_ffprobe_binary'] ?? ''; // 1. Try the configured ffprobe path. if ( '' !== $custom ) { if ( @file_exists( $custom ) && @is_executable( $custom ) ) { $resolved = $custom; return $resolved; } } // 2. Try the same directory as resolved ffmpeg. $ffmpeg_path = $this->cache->find_ffmpeg(); if ( null !== $ffmpeg_path ) { $candidate = dirname( $ffmpeg_path ) . '/ffprobe'; if ( @is_executable( $candidate ) ) { $resolved = $candidate; return $resolved; } } // 3. Fallback: common paths extended via filter. $candidates = apply_filters( 'piperless_ffprobe_paths', [ '/usr/bin/ffprobe', '/usr/local/bin/ffprobe', '/opt/bin/ffprobe' ] ); foreach ( $candidates as $candidate ) { if ( @is_executable( $candidate ) ) { $resolved = $candidate; return $resolved; } } return null; } }