Piperless
WordPress Plugin — Audio Transcripts via Piper TTS · v1.1.4 · Generated 2026-05-11
Overview
Piperless generates audio transcripts of WordPress posts using the Piper neural text-to-speech engine.
It shells out to the Piper CLI via proc_open, caches the resulting WAV/MP3 files in
wp-content/uploads/piperless/, and serves them through a REST API proxy with an
HTML5 audio player on the frontend.
- Requires PHP 8.0+, WordPress 6.0+
- MIT
- 8 PHP classes, ~4,000 lines
- 7 admin panel tabs, 4 REST API endpoints
- 6 player themes + Custom CSS
- 8 complete translations — DE, FR, ES, IT, JA, NL, PT, ZH (118 strings each)
Architecture
Data flow for audio generation:
REST POST /generate → Gutenberg::rest_generate() → Transcriber::generate()
→ extract text → SHA-256 cache key → check cache → acquire mutex →
Piper::synthesise() → Cache_Manager::put() → update post meta → release mutex.
Plugin (Orchestrator)
includes/class-plugin.php — Singleton
The entry point. Instantiated once via Plugin::instance() and wired into WordPress
via piperless_init() on plugins_loaded.
Properties
| Name | Type | Description |
|---|---|---|
| $logger | Logger | PSR-3 logger instance |
| $piper | Piper | TTS CLI wrapper |
| $settings | Settings | Admin panel |
| $transcriber | Transcriber | Generation orchestrator |
| $cache_manager | Cache_Manager | Audio file cache |
| $player | Player | Frontend HTML5 player |
| $gutenberg | Gutenberg | Block editor sidebar |
Key Methods
| Method | Description |
|---|---|
| init() | Fires all subsystem init() calls, registers transition_post_status hook and piperless_auto_generate cron action |
| maybe_auto_generate() | Fires on transition_post_status. When a post transitions to 'publish' from a non-publish state, auto-generate is enabled, and no audio URL exists yet, schedules a one-shot cron event 5 seconds in the future. This defers the expensive Piper call off the HTTP response path. |
| do_auto_generate() | Cron callback — re-checks for existing audio as a safety net before calling Transcriber::generate() |
Piper (TTS Engine)
includes/class-piper.php — 25 methods
Wraps the Piper CLI tool. Communicates via proc_open in three modes.
Interface Modes
| Mode | How it works | Used by |
|---|---|---|
| raw | Text → stdin, raw PCM ← stdout, wrapped in WAV. Streamed to temp file for large outputs. | Native Piper CLI (--output-raw) |
| file | Text → stdin, Piper writes WAV via --output_file, read from disk. | Python wheel wrappers |
| positional | Binary, model path, and text passed as positional args. Wrapper writes output.wav in CWD. Runs in isolated temp dir. | Custom wrapper scripts |
Key Methods
| Method | Visibility | Description |
|---|---|---|
| synthesise() | public | Entry point. Validates binary/model paths, applies set_time_limit guard, dispatches to mode-specific method. Timeout restored in finally block. |
| scan_models() | public | Recursively walks models directory for .onnx files. Parses filenames into voice/language/quality. Validates companion .json. Result cached per-request. |
| find_model() | public | Fuzzy model lookup: exact match → voice+language → language-only → first available. Calls scan_models() (uses cache). |
| detect_output_mode() | public | Auto-detects Piper's interface mode from --help output. Cached for process lifetime. Respects piper_interface setting override. |
| test() | public | Pre-flight check: validates binary path, open_basedir access, file existence, executability. Runs --help to extract version. Tolerates wrappers without --help support. |
| is_path_accessible() | private | Checks if a path is within PHP's open_basedir using pure string-prefix matching — no filesystem calls on blocked paths. Stops at root to avoid file_exists('/') warnings. |
| is_valid_model_config() | public | Validates .onnx.json companion files: detects Git LFS pointers, empty files, malformed JSON. |
synthesise() reads piper_process_timeout from settings
(default 300s, clamped 30–3600) and calls set_time_limit() before proc_open.
The original limit is restored in a finally block. All three synthesis methods use
escapeshellarg() — zero escapeshellcmd() calls remain.
Transcriber
includes/class-transcriber.php
Generation Pipeline
- Resolve voice / language / quality from per-post meta → REST params → settings
- Extract text — manual excerpt first, then content with optional embedded-block filtering
- Generate SHA-256 cache key from text + model + language + quality + bitrate
- Return cached audio if it exists
- Acquire mutex transient (piperless_synthesising_<key>) — prevents parallel Piper processes
- Shell out to Piper, wrap raw PCM in WAV
- Store in cache (MP3 via ffmpeg if available, WAV otherwise)
- Update post meta, release mutex
Concurrency Mutex
A transient-based lock prevents two requests from running Piper simultaneously for identical content. The lock is released on all exit paths: synthesis failure, cache-write failure, and success. A 5-minute TTL acts as a safety net against stranded locks.
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 filter) are stripped. Text is collected from innerHTML
recursively through the block tree — avoiding double-processing through render_block() +
the_content.
Cache Manager
includes/class-cache-manager.php
Storage
Files stored in wp-content/uploads/piperless/. Content-addressed via SHA-256 hash of
text + model + language + quality + bitrate — identical input always produces the same key.
MP3 Conversion
When ffmpeg is available (auto-detected from common paths or configured via
piper_ffmpeg_binary), WAV data is written to a temp file, converted to MP3 via
exec(), and stored as .mp3. If ffmpeg is unavailable or conversion fails,
the raw WAV is stored as .wav. Both formats coexist; the REST proxy prefers MP3.
Orphan Detection
clear_orphans() cross-references every file against _piperless_cache_key
post meta across all posts. Files with no matching post are deleted. Model preview and test
preview files are silently cleaned up but not counted.
Key Methods
| Method | Description |
|---|---|
| put() | Store audio: WAV → temp → ffmpeg → MP3, or WAV fallback |
| get() | Retrieve cached audio data |
| delete() | Delete both MP3 and legacy WAV for a key |
| flush() | Delete all cached audio files |
| clear_orphans() | Delete files not referenced by any post + preview files |
| stats() | Return file count and total bytes (excludes preview files) |
| get_entries() | Paginated cache browser data with post associations |
Gutenberg Integration
includes/class-gutenberg.php
Sidebar Panels
| Panel | Fields |
|---|---|
| Generation | Generate/Regenerate button, Player Title, Remove Audio link |
| Voice Settings | Voice, Language, Quality, Sentence Silence, Length Scale |
| Display Settings | Player Style, Placement |
| Preview | HTML5 audio player with duration display |
Per-Post Meta
| Meta Key | Type | Purpose |
|---|---|---|
| _piperless_voice | string | Voice name override |
| _piperless_language | string | Language code override |
| _piperless_quality | string | Quality tier override |
| _piperless_style | string | Player theme override |
| _piperless_placement | string | Player placement override |
| _piperless_title | string | Player title override |
| _piperless_sentence_silence | string | Sentence silence override |
| _piperless_length_scale | string | Length scale override |
| _piperless_audio_url | string | Current audio proxy URL |
| _piperless_duration | number | Duration in seconds |
REST API
| Method | Route | Auth | Description |
|---|---|---|---|
| GET | /piperless/v1/audio?key=… | Public | Stream cached audio. Rate-limited 60 req/min/IP. Supports HTTP Range (206 Partial Content) for seeking. |
| POST | /piperless/v1/generate | edit_post | Trigger audio generation. Checks per-post ownership. |
| GET | /piperless/v1/status/<id> | edit_post | Check audio status for a post. |
| GET | /piperless/v1/models | edit_posts | List available voice models (paths stripped to basename). |
| DELETE | /piperless/v1/audio/<id> | edit_post | Remove audio: deletes cached files from disk, clears post meta. |
Settings Panel
includes/class-settings.php
Tabs
| Tab | Section | Fields |
|---|---|---|
| Piper | Piper TTS Configuration | Binary path, models directory, interface mode, model preview table, default voice/language/quality, FFmpeg path, MP3 bitrate, sentence silence, length scale, Test Connection button |
| Content | Content Parsing | Auto-generate on publish, Skip embedded content |
| Styling | Audio Player Settings | Player preview, player style, player max width, custom CSS, player placement, player title, show duration |
| Performance | — | Piper process timeout (30–3600s), audio endpoint rate limit (1–600 req/min) |
| Cache Management | — | Cache stats, clear orphaned audio, flush entire cache, cache browser with pagination |
| Logs | — | Logging level, debug log viewer, refresh/clear buttons |
| Help | — | Usage instructions |
| About | — | Version and contact info |
Architecture Note
The Piper, Content, Styling, and Performance tabs share a single <form> (same option group).
Each tab group uses a separate "virtual" page slug passed to add_settings_section() and
do_settings_sections(). render_page() switches which sections are rendered
based on the ?tab= query parameter. Custom field types (model_preview_table,
player_preview_block, voice_select) are dispatched through render_field().
Audio Player
includes/class-player.php
Themes
| CSS Class | Name | Accent |
|---|---|---|
| piperless-player--classic | Classic | Blue accent, clean borders |
| piperless-player--minimal | Minimal | Clean, understated |
| piperless-player--dark | Modern Dark | Dark background |
| piperless-player--newsviews | Ron Burgundy | Bold burgundy |
| piperless-player--newsviews-classic | Dan Rather Blue | Classic navy |
| — | Custom CSS | User-defined via textarea (sanitized) |
Placement
| Value | Behavior |
|---|---|
| before | Player before post content |
| after | Player after post content |
| both | Player both before and after content |
| manual | No automatic insertion — use [piperless_player] shortcode |
Logger
includes/class-logger.php
Severity Levels (PSR-3)
| Level | Value | Typical Use |
|---|---|---|
| emergency | 0 | System unusable |
| alert | 1 | Immediate action required |
| critical | 2 | Critical conditions |
| error | 3 | Runtime errors |
| warning | 4 | Exceptional but non-error |
| notice | 5 | Normal but significant |
| info | 6 | Interesting events |
| debug | 7 | Detailed debug information |
Output Channels
- WordPress debug.log — when WP_DEBUG + WP_DEBUG_LOG are enabled
- piperless.log — dedicated file in wp-content/uploads/piperless/
- PHP error_log() — fallback when the dedicated file can't be written
The dedicated log file is chmod 0600 after every write to prevent world-readable access
on servers without .htaccess protection. log_last_error() captures error_get_last()
after @-suppressed filesystem operations — always call error_clear_last() before the @ call
for deterministic attribution.
Filters & Actions
Filters (extend behaviour)
| Hook | Type | Default | Purpose |
|---|---|---|---|
| piperless_post_types | apply_filters | ['post', 'page'] | Post types supported by the plugin |
| piperless_quality_tiers | apply_filters | ['low','medium','high','lite','small','fast','quality'] | Recognized quality tier labels in model filenames |
| piperless_skip_blocks | apply_filters | ['core/embed', 'core-embed/'] | Block name prefixes to skip during text extraction |
| piperless_ffmpeg_paths | apply_filters | ['/usr/bin/ffmpeg','/usr/local/bin/ffmpeg','/opt/bin/ffmpeg'] | ffmpeg binary paths to probe |
Actions (hook into)
| Hook | Purpose |
|---|---|
| piperless_auto_generate | Cron event for deferred auto-generation (receives post ID) |
Shortcodes
| Shortcode | Attributes | Purpose |
|---|---|---|
| [piperless_player] | post_id (optional) | Render audio player for a specific post or current post |
Security Model
Comprehensive security audit: 29/29 categories cleared, 1 finding fixed, zero open. Read the full audit →
Command Execution
- All binary/model paths use
escapeshellarg()— zeroescapeshellcmd()calls - Binary path validated with file_exists() + is_executable() before use
- Model paths come from admin settings or validated scan results, not user input
- FFmpeg path auto-detected from allowlisted directories
Authorization
- All AJAX handlers: nonce +
manage_optionscapability - REST generate/status/remove:
edit_posts+current_user_can('edit_post', $post_id) - REST audio proxy: intentionally public (required by frontend players), rate-limited 60 req/min/IP, cache keys regex-validated
- REST models:
edit_posts, absolute paths stripped to basename
Input/Output
- All user input:
sanitize_text_field,(int)casts, regex validation - All output:
esc_html,esc_attr,esc_url,esc_textarea - Custom CSS sanitized: HTML tags stripped, lines with
url(),expression(),@import,behavior:removed
File Security
- Log file:
chmod 0600after every write - Cache directory:
.htaccesswithDeny from all,index.phpsilence file - Audio served exclusively through PHP proxy with key validation and Range request support
- Temp files cleaned up on all code paths (success, failure, early return)
Runtime Safety
set_time_limit()guard around proc_open (configurable, 30–3600s)- Synthesis mutex per cache key (transient, 5-min TTL)
- Auto-generate deferred to cron (off HTTP response path)
- open_basedir checks with string matching before filesystem calls
- @ suppression on filesystem calls with error_clear_last() + log_last_error() for diagnostics
Build & Tooling
The Makefile wraps the build and translation toolchain:
| Command | Action |
|---|---|
make build | Create piperless-X.Y.Z.zip |
make translations | Extract .pot → JSON, sync to all locales |
make json2po | Convert JSON translations back to .po/.mo |
make check-translations | Validate translation integrity |
make lock-translations | Lock all locales for translation work |
make unlock-translations | Release all translation locks |
make translation-status | Show lock/completion for each locale |
make clean | Remove build artifacts |
All tools run as standalone shell scripts in tools/ — the Makefile is a convenience wrapper.