'object', 'single' => true, 'show_in_rest' => false, 'auth_callback' => static function () { return current_user_can( 'manage_options' ); } ) ); add_filter( 'is_protected_meta', static function ( $protected, $key, $type ) { return 'post' === $type && Audio_Access::PRIVATE_META === $key ? true : $protected; }, 10, 3 ); add_action( self::REPAIR_HOOK, array( __CLASS__, 'repair_compatibility' ), 10, 3 ); } /** Describe physical audio; options and accounting projections are not provenance. */ public static function describe( int $post_id ): array { $empty = array( 'has_recording' => false, 'model_id' => null, 'voice_id' => null, 'voice_name' => '', 'generated_at' => null, 'provenance' => 'unknown', 'recording_signature' => '' ); $file = Audio_Access::resolve_audio_file( $post_id ); if ( ! $file || ! is_file( $file['path'] ) || ! is_readable( $file['path'] ) ) { return $empty; } $empty['has_recording'] = true; clearstatcache( true, $file['path'] ); $binding = array( $file['relative_path'], filesize( $file['path'] ), filemtime( $file['path'] ) ); $private = get_post_meta( $post_id, Audio_Access::PRIVATE_META, true ); $recording = is_array( $private ) && ( $private['relative_path'] ?? null ) === $file['relative_path'] ? ( $private['recording'] ?? null ) : null; if ( self::valid( $recording, true ) ) { $empty['model_id'] = $recording['model_id']; $empty['voice_id'] = '' !== $recording['voice_id'] ? $recording['voice_id'] : null; $empty['voice_name'] = $recording['voice_name']; $empty['generated_at'] = $recording['generated_at']; $empty['provenance'] = $recording['provenance']; $binding[] = self::generation_identity( $private ); if ( isset( $recording['audio_sha256'] ) ) { $binding[] = $recording['audio_sha256']; } // Older callers can resolve the friendly name after publication. Match // its voice ID before using it, so a new voice never inherits an old name. if ( '' === $empty['voice_name'] && get_post_meta( $post_id, 'accessibility_audio_tts_voice_id', true ) === $recording['voice_id'] ) { $empty['voice_name'] = sanitize_text_field( get_post_meta( $post_id, 'accessibility_audio_tts_voice_name', true ) ); } } else { $voice = get_post_meta( $post_id, 'accessibility_audio_tts_voice_id', true ); $empty['voice_id'] = self::identifier( $voice ) ? $voice : null; $empty['voice_name'] = sanitize_text_field( get_post_meta( $post_id, 'accessibility_audio_tts_voice_name', true ) ); $empty['generated_at'] = self::legacy_timestamp( get_post_meta( $post_id, 'accessibility_audio_tts_generated_date', true ) ); $model = get_post_meta( $post_id, 'accessibility_audio_tts_model', true ); if ( ElevenLabs_Models::is_supported( $model ) ) { $empty['model_id'] = $model; $empty['provenance'] = 'legacy_meta'; } } $binding[] = array( $empty['model_id'], $empty['voice_id'] ); $empty['recording_signature'] = hash( 'sha256', wp_json_encode( $binding ) ); return $empty; } /** Freeze the actual generation inputs before sending the first paid chunk. */ public static function capture( \WP_Post $post, string $model, string $voice, string $prepared_text ): array { if ( ! ElevenLabs_Models::is_supported( $model ) || ! self::identifier( $voice ) ) { throw new \InvalidArgumentException( 'Invalid recording model or voice.' ); } return array( 'schema' => 1, 'provenance' => 'recorded', 'model_id' => $model, 'voice_id' => $voice, 'voice_name' => '', 'generated_at' => time(), 'plugin_version' => defined( 'ACCESSIBILITY_AUDIO_TTS_VERSION' ) ? ACCESSIBILITY_AUDIO_TTS_VERSION : 'unknown', 'source_sha256' => hash( 'sha256', wp_json_encode( array( $post->post_title, $post->post_content, $post->post_excerpt ?? '' ) ) ), 'text_sha256' => hash( 'sha256', $prepared_text ), 'generation_id' => bin2hex( random_bytes( 16 ) ), ); } /** Validate imported provenance before associating it with these exact bytes. */ public static function for_publication( array $recording, string $relative, string $path ): array { if ( ! self::valid( $recording, false ) || 'recorded' !== $recording['provenance'] || ! Audio_Access::valid_relative_path( $relative ) || ! is_file( $path ) || is_link( $path ) ) { throw new \InvalidArgumentException( 'Invalid recording provenance.' ); } $hash = hash_file( 'sha256', $path ); if ( ! is_string( $hash ) || ( isset( $recording['audio_sha256'] ) && ! hash_equals( $recording['audio_sha256'], $hash ) ) ) { throw new \RuntimeException( 'Recording provenance does not match the audio file.' ); } $recording['audio_sha256'] = $hash; return $recording; } /** Strict portable schema. Legacy evidence stays explicitly weaker than capture. */ public static function valid( $recording, bool $require_file_hash = true ): bool { if ( ! is_array( $recording ) || 1 !== ( $recording['schema'] ?? null ) || ! in_array( $recording['provenance'] ?? null, array( 'recorded', 'legacy_meta', 'unknown' ), true ) ) { return false; } $required = array( 'schema', 'provenance', 'model_id', 'voice_id', 'voice_name', 'generated_at' ); if ( 'recorded' === $recording['provenance'] ) { $required = array_merge( $required, array( 'plugin_version', 'source_sha256', 'text_sha256', 'generation_id' ) ); if ( $require_file_hash ) { $required[] = 'audio_sha256'; } } $allowed = 'recorded' === $recording['provenance'] ? array_merge( $required, array( 'audio_sha256' ) ) : $required; if ( array_diff( $required, array_keys( $recording ) ) || array_diff( array_keys( $recording ), $allowed ) ) { return false; } if ( ( 'unknown' === $recording['provenance'] ? null !== $recording['model_id'] : ! self::identifier( $recording['model_id'] ) ) || ! is_string( $recording['voice_id'] ) || ( '' !== $recording['voice_id'] && ! self::identifier( $recording['voice_id'] ) ) || ! is_string( $recording['voice_name'] ) || strlen( $recording['voice_name'] ) > 300 || false !== strpos( $recording['voice_name'], "\0" ) ) { return false; } if ( null !== $recording['generated_at'] && ( ! is_int( $recording['generated_at'] ) || $recording['generated_at'] <= 0 || $recording['generated_at'] > 4102444800 ) ) { return false; } if ( 'legacy_meta' === $recording['provenance'] ) { return ElevenLabs_Models::is_supported( $recording['model_id'] ); } if ( 'unknown' === $recording['provenance'] ) { return true; } if ( ! is_int( $recording['generated_at'] ) || '' === $recording['voice_id'] || ! is_string( $recording['plugin_version'] ) || ! preg_match( '/^[a-zA-Z0-9_.+-]{1,40}$/D', $recording['plugin_version'] ) || ! is_string( $recording['generation_id'] ) || ! preg_match( '/^[a-f0-9]{32}$/D', $recording['generation_id'] ) ) { return false; } foreach ( array( 'source_sha256', 'text_sha256', 'audio_sha256' ) as $key ) { if ( ( 'audio_sha256' !== $key || array_key_exists( $key, $recording ) || $require_file_hash ) && ( ! isset( $recording[ $key ] ) || ! is_string( $recording[ $key ] ) || ! preg_match( '/^[a-f0-9]{64}$/D', $recording[ $key ] ) ) ) { return false; } } return true; } /** Portable provenance, including explicitly labelled pre-upgrade metadata. */ public static function for_backup( int $post_id ): ?array { $description = self::describe( $post_id ); if ( ! $description['has_recording'] ) { return null; } if ( 'recorded' === $description['provenance'] ) { $file = get_post_meta( $post_id, Audio_Access::PRIVATE_META, true ); $resolved = Audio_Access::resolve_audio_file( $post_id ); $recording = $file['recording']; $recording['voice_name'] = $description['voice_name']; return self::for_publication( $recording, $resolved['relative_path'], $resolved['path'] ); } return array( 'schema' => 1, 'provenance' => $description['provenance'], 'model_id' => $description['model_id'], 'voice_id' => $description['voice_id'] ?? '', 'voice_name' => $description['voice_name'], 'generated_at' => $description['generated_at'] ); } /** Compatibility fields are projections of recorded facts, never current options. */ public static function compatibility( array $recording ): array { if ( ! self::valid( $recording, false ) ) { throw new \InvalidArgumentException( 'Invalid recording provenance.' ); } return array( 'model' => $recording['model_id'] ?? '', 'voice_id' => $recording['voice_id'], 'voice_name' => sanitize_text_field( $recording['voice_name'] ), 'generated_date' => $recording['generated_at'] ? wp_date( 'Y-m-d H:i:s', $recording['generated_at'], wp_timezone() ) : '' ); } public static function clear_compatibility( int $post_id ): void { foreach ( array( 'model', 'voice_id', 'voice_name', 'generated_date' ) as $key ) { delete_post_meta( $post_id, 'accessibility_audio_tts_' . $key ); } } /** * Flat fields support old screens and SQL filters. Once the canonical binding * is committed they are repairable projections, never a reason to regenerate. */ public static function synchronize_compatibility( int $post_id, ?string $expected_generation = null, int $attempt = 0 ): bool { $owned = null; $previous_context = null; $generation = $expected_generation; try { if ( ! get_post( $post_id ) ) { return true; } wp_cache_delete( $post_id, 'post_meta' ); $binding = get_post_meta( $post_id, Audio_Access::PRIVATE_META, true ); if ( ! is_array( $binding ) || ! self::valid( $binding['recording'] ?? null, true ) ) { return true; } $generation = self::generation_identity( $binding ); if ( null !== $expected_generation && ! hash_equals( $expected_generation, $generation ) ) { return true; } if ( ! Generation_Lease::current( $post_id ) ) { $owned = Generation_Lease::acquire( $post_id ); if ( ! $owned ) { self::defer_compatibility( $post_id, $generation, $attempt + 1 ); return false; } $previous_context = $owned->activate(); } Generation_Lease::checkpoint( $post_id ); // Reload after acquiring the shared lease, including for stale cron jobs. wp_cache_delete( $post_id, 'post_meta' ); $binding = get_post_meta( $post_id, Audio_Access::PRIVATE_META, true ); if ( ! is_array( $binding ) || ! self::valid( $binding['recording'] ?? null, true ) || ! hash_equals( $generation, self::generation_identity( $binding ) ) ) { return true; } $path = Audio_Access::import_destination( $post_id, $binding['relative_path'] ); if ( ! is_file( $path ) ) { return true; } $recording = $binding['recording']; $projection = self::compatibility( $recording ); if ( '' === $projection['voice_name'] && get_post_meta( $post_id, 'accessibility_audio_tts_voice_id', true ) === $recording['voice_id'] ) { $projection['voice_name'] = sanitize_text_field( get_post_meta( $post_id, 'accessibility_audio_tts_voice_name', true ) ); } foreach ( $projection as $key => $value ) { Generation_Lease::checkpoint( $post_id ); self::write_compatibility( $post_id, 'accessibility_audio_tts_' . $key, $value ); } return true; } catch ( \Throwable $error ) { if ( is_string( $generation ) && '' !== $generation ) { self::defer_compatibility( $post_id, $generation, $attempt + 1 ); } return false; } finally { if ( $owned ) { try { $owned->deactivate( $previous_context ); $owned->release(); } catch ( \Throwable $error ) {} } } } public static function repair_compatibility( int $post_id, string $generation, int $attempt = 1 ): void { if ( $attempt < 1 || $attempt > self::MAX_REPAIR_ATTEMPTS ) { return; } self::synchronize_compatibility( $post_id, $generation, $attempt ); } private static function write_compatibility( int $post_id, string $key, $value ): void { global $wpdb; update_post_meta( $post_id, $key, $value ); if ( $wpdb->last_error ) { throw new \RuntimeException( 'Recording metadata projection could not be saved.' ); } wp_cache_delete( $post_id, 'post_meta' ); $actual = get_post_meta( $post_id, $key, true ); if ( $wpdb->last_error || (string) $actual !== (string) $value ) { throw new \RuntimeException( 'Recording metadata projection could not be saved.' ); } } private static function generation_identity( array $binding ): string { $recording = $binding['recording']; return isset( $recording['generation_id'] ) ? $recording['generation_id'] : hash( 'sha256', wp_json_encode( array( $binding['relative_path'], $recording ) ) ); } /** A failed projection gets at most three local retries; it never calls a provider. */ private static function defer_compatibility( int $post_id, string $generation, int $attempt ): void { try { accessibility_audio_tts_debug_log( 'Recording metadata projection deferred for post ' . $post_id . '; the active recording remains available.' ); if ( $attempt > self::MAX_REPAIR_ATTEMPTS ) { return; } $args = array( $post_id, $generation, $attempt ); if ( ! wp_next_scheduled( self::REPAIR_HOOK, $args ) ) { wp_schedule_single_event( time() + min( 300, 60 * ( 2 ** ( $attempt - 1 ) ) ), self::REPAIR_HOOK, $args, true ); } } catch ( \Throwable $error ) { // Diagnostics/scheduling must not convert a published recording into a failure. } } private static function identifier( $value ): bool { return is_string( $value ) && (bool) preg_match( '/^[a-zA-Z0-9_-]{1,100}$/D', $value ); } private static function legacy_timestamp( $date ): ?int { if ( ! is_string( $date ) || ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $date ) ) { return null; } $value = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $date, wp_timezone() ); return $value && $value->format( 'Y-m-d H:i:s' ) === $date ? $value->getTimestamp() : null; } }