Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
178b184
Add a few new helper methods for TTS
dkotter Jul 16, 2026
292149e
Add a content chunker class used to chunk content down before we do a…
dkotter Jul 16, 2026
df7a1f2
Add an audio combiner class that takes in multiple audio files and co…
dkotter Jul 16, 2026
73e466d
Add a speech generator class that takes in some content and sends tha…
dkotter Jul 16, 2026
3523125
Add a job manager class that is responsible for the bulk of the work.…
dkotter Jul 16, 2026
b34c528
Register the TTS experiment and all needed hooks
dkotter Jul 16, 2026
b38eddf
Add a TTS REST controller to handle starting jobs and checking on the…
dkotter Jul 16, 2026
ad66bc0
Add a generate-speech ability that can be used to generate speech for…
dkotter Jul 16, 2026
73977b7
Add a import-base64-audio ability that takes in base64 encoded data a…
dkotter Jul 16, 2026
a358772
Add additional test coverage
dkotter Jul 16, 2026
0c63fd4
Add additional test coverage
dkotter Jul 16, 2026
cf878eb
Add the necessary client-side code to render both in the editor and t…
dkotter Jul 16, 2026
6c8400a
Add E2E tests
dkotter Jul 16, 2026
38ca34a
Add docs
dkotter Jul 16, 2026
db236e6
Show a loading state immediately after clicking the button
dkotter Jul 16, 2026
76f6936
Merge branch 'develop' into feature/text-to-speech
dkotter Jul 21, 2026
c9b11ad
Ensure we add the title to the post content. Adjust some styling in t…
dkotter Jul 21, 2026
efa6341
Better handling for posts that don't have a title set
dkotter Jul 21, 2026
51506de
Don't force MP3 format when we make our request
dkotter Jul 21, 2026
9a444a0
Add the ability to delete a generated audio file and all associated m…
dkotter Jul 21, 2026
b80097e
Ensure we use the right capability so setting a custom provider works
dkotter Jul 21, 2026
373a9e1
Merge branch 'develop' into feature/text-to-speech
dkotter Aug 19, 2026
f8f868b
Add voice resolution and defaulting to the Text to Speech experiment
saarnilauri Aug 21, 2026
a5c7bd5
Update docs
dkotter Aug 26, 2026
af0b567
Better messaging for the default voice or when a previously chosen vo…
dkotter Aug 26, 2026
c55a49d
Ensure we only resolve the default once and then freeze that result s…
dkotter Aug 26, 2026
8ec01f7
Move error handling to the right place and ensure we look for WP_Error
dkotter Aug 26, 2026
ef1ad5f
Remove use of transient as there's already caching upstream, instead …
dkotter Aug 26, 2026
92baf4f
Add tests
dkotter Aug 26, 2026
6b1c28b
Merge branch 'develop' into feature/text-to-speech
dkotter Aug 26, 2026
ed2d9b5
Merge pull request #5 from saarnilauri/tts-voice-resolver
dkotter Aug 26, 2026
e7d4b51
Slightly modified check to ensure preferred models match the format w…
dkotter Aug 26, 2026
cd9b903
Merge branch 'develop' into feature/text-to-speech
dkotter Sep 29, 2026
a0f382a
Ensure the audio we import is valid base64 data
dkotter Sep 29, 2026
da8e6ca
Ensure TTS is a valid capability in our REST model
dkotter Sep 29, 2026
7f70e53
Ensure our meta keys are private. Return an error once the job goes s…
dkotter Sep 29, 2026
9239560
Update docs
dkotter Sep 29, 2026
37492c1
Add new tests
dkotter Sep 29, 2026
1449a64
Add helper text to the voice input
dkotter Sep 29, 2026
4722659
Change how we cancel stale jobs, ensuring we don't have to wait for 1…
dkotter Sep 29, 2026
992a8c2
Fix E2E tests
dkotter Sep 29, 2026
5afd77b
Merge branch 'develop' into feature/text-to-speech
dkotter Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Add the necessary client-side code to render both in the editor and t…
…he front-end
  • Loading branch information
dkotter committed Jul 16, 2026
commit cf878ebc2c0d7fce3b9ca4afce93b91b3db2036a
126 changes: 126 additions & 0 deletions src/experiments/text-to-speech/components/TextToSpeechPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Text to Speech sidebar panel contents.
*/

/**
* WordPress dependencies
*/
import { Button, Notice, ToggleControl } from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import { useSpeechGeneration } from './useSpeechGeneration';
import type { TextToSpeechData } from '../types';

/**
* Get the settings for the Text to Speech panel.
*
* @return {TextToSpeechData} The settings for the Text to Speech panel.
*/
const getSettings = (): TextToSpeechData => {
const settings = ( window as any ).aiTextToSpeechData ?? {};

return {
enabled: settings.enabled ?? false,
hasTtsSupport: settings.hasTtsSupport ?? false,
};
};

/**
* Panel component with the generate button, progress, preview, and the
* front-end display toggle.
*
* @return {React.JSX.Element} The Text to Speech panel component.
*/
export default function TextToSpeechPanel(): React.JSX.Element {
const { hasTtsSupport } = getSettings();
const {
status,
isGenerating,
isBlockedByUnsavedChanges,
hasAudio,
audioUrl,
displayAudio,
setDisplayAudio,
handleGenerate,
} = useSpeechGeneration();

return (
<>
{ ! hasTtsSupport && (
<Notice status="warning" isDismissible={ false }>
{ __(
'No connected AI provider supports text to speech.',
'ai'
) }
</Notice>
) }

{ isGenerating && status && (
<p className="ai-text-to-speech-panel__progress">
{ sprintf(
/* translators: 1: number of chunks processed, 2: total number of chunks */
__( 'Generating audio… (%1$d of %2$d)', 'ai' ),
status.done,
status.total
) }
</p>
) }

{ hasAudio && ! isGenerating && audioUrl && (
<audio
className="ai-text-to-speech-panel__preview"
controls
preload="metadata"
src={ audioUrl }
/>
) }

{ hasAudio && (
<ToggleControl
label={ __(
'Display audio player on the front end',
'ai'
) }
checked={ displayAudio }
onChange={ setDisplayAudio }
/>
) }

<Button
__next40pxDefaultSize
variant="secondary"
onClick={ handleGenerate }
isBusy={ isGenerating }
disabled={
! hasTtsSupport || isGenerating || isBlockedByUnsavedChanges
}
accessibleWhenDisabled
>
{ hasAudio
? __( 'Regenerate Audio', 'ai' )
: __( 'Generate Audio', 'ai' ) }
</Button>

{ isBlockedByUnsavedChanges && ! isGenerating && (
<p className="ai-text-to-speech-panel__help">
{ __(
'Save your changes first — audio is generated from the saved content.',
'ai'
) }
</p>
) }

{ hasAudio && ! isGenerating && (
<p className="ai-text-to-speech-panel__help">
{ __(
'Regenerating deletes the current audio and creates a new version.',
'ai'
) }
</p>
) }
</>
);
}
140 changes: 140 additions & 0 deletions src/experiments/text-to-speech/components/useSpeechGeneration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Shared hook for text to speech generation logic.
*/

/**
* WordPress dependencies
*/
import apiFetch from '@wordpress/api-fetch';
import { dispatch, useDispatch, useSelect } from '@wordpress/data';
import { store as editorStore } from '@wordpress/editor';
import { useCallback, useEffect, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';

/**
* Internal dependencies
*/
import { ensureProvider } from '../../../utils/provider-status';
import type { TtsStatus } from '../types';

const NOTICE_ID = 'ai_text_to_speech_error';
const POLL_INTERVAL = 5000;

/**
* Hook for text to speech generation functionality.
*
* Starts background generation via POST /ai/v1/text-to-speech/{id} and polls
* the same route with GET while a job is running. The job itself runs
* server-side in WP-Cron, so it survives the editor being closed; re-opening
* the editor resumes polling from the persisted status.
*
* @return {Object} Object with generation state and handlers.
*/
export function useSpeechGeneration(): {
status: TtsStatus | null;
isGenerating: boolean;
isBlockedByUnsavedChanges: boolean;
hasAudio: boolean;
audioUrl: string;
displayAudio: boolean;
setDisplayAudio: ( value: boolean ) => void;
handleGenerate: () => Promise< void >;
} {
const { postId, isDirty, isSaving, meta } = useSelect( ( select ) => {
return {
postId: select( editorStore ).getCurrentPostId(),
isDirty: select( editorStore ).isEditedPostDirty(),
isSaving: select( editorStore ).isSavingPost(),
meta: select( editorStore ).getEditedPostAttribute( 'meta' ) as
| { wpai_tts_display_audio?: boolean }
| undefined,
};
}, [] );
const { editPost } = useDispatch( editorStore );
const [ status, setStatus ] = useState< TtsStatus | null >( null );

const fetchStatus = useCallback( async (): Promise< TtsStatus | null > => {
if ( ! postId ) {
return null;
}

try {
const result = await apiFetch< TtsStatus >( {
path: `/ai/v1/text-to-speech/${ postId }`,
} );
setStatus( result );
return result;
} catch {
return null;
}
}, [ postId ] );

// Load persisted status when the editor opens.
useEffect( () => {
fetchStatus();
}, [ fetchStatus ] );

const isGenerating =
status?.status === 'pending' || status?.status === 'processing';

// Poll while a background job is running. Polling also keeps WP-Cron
// spawning on low-traffic sites.
useEffect( () => {
if ( ! isGenerating ) {
return undefined;
}

const intervalId = window.setInterval( async () => {
const result = await fetchStatus();

if ( result?.status === 'error' ) {
dispatch( noticesStore ).createErrorNotice(
result.error || __( 'Audio generation failed.', 'ai' ),
{ id: NOTICE_ID, isDismissible: true }
);
}
}, POLL_INTERVAL );

return () => window.clearInterval( intervalId );
}, [ isGenerating, fetchStatus ] );

const handleGenerate = async () => {
if ( ! ensureProvider( NOTICE_ID ) ) {
return;
}

dispatch( noticesStore ).removeNotice( NOTICE_ID );

try {
const result = await apiFetch< TtsStatus >( {
path: `/ai/v1/text-to-speech/${ postId }`,
method: 'POST',
} );
setStatus( result );
} catch ( error: any ) {
dispatch( noticesStore ).createErrorNotice(
error?.message ??
__( 'Failed to start audio generation.', 'ai' ),
{ id: NOTICE_ID, isDismissible: true }
);
}
};

const displayAudio = meta?.wpai_tts_display_audio ?? true;

const setDisplayAudio = ( value: boolean ) => {
editPost( { meta: { wpai_tts_display_audio: value } } );
};

return {
status,
isGenerating,
isBlockedByUnsavedChanges: Boolean( isDirty || isSaving ),
hasAudio: Boolean( status?.audio_id ),
audioUrl: status?.audio_url ?? '',
displayAudio,
setDisplayAudio,
handleGenerate,
};
}
29 changes: 29 additions & 0 deletions src/experiments/text-to-speech/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Front-end player (rendered via the_content filter in PHP).
.wpai-tts-player {
display: flex;
flex-direction: column;
gap: 0.5em;
margin-bottom: 1.5em;

audio {
width: 100%;
}

&__label {
font-weight: 600;
}
}

// Editor sidebar panel.
.ai-text-to-speech-panel {
&__preview {
width: 100%;
margin-bottom: 8px;
}

&__help {
margin-top: 8px;
color: #757575;
font-size: 12px;
}
}
45 changes: 45 additions & 0 deletions src/experiments/text-to-speech/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Text to Speech experiment plugin registration.
*/

/**
* WordPress dependencies
*/
import { PluginDocumentSettingPanel } from '@wordpress/editor';
import { registerPlugin } from '@wordpress/plugins';
import { __ } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import TextToSpeechPanel from './components/TextToSpeechPanel';
import './index.scss';

import type { TextToSpeechData } from './types';

const localized = ( window as any ).aiTextToSpeechData as
| TextToSpeechData
| undefined;

/**
* Plugin component that renders the Text to Speech panel in the editor sidebar.
*/
const TextToSpeechPlugin = (): React.JSX.Element | null => {
if ( ! localized?.enabled ) {
return null;
}

return (
<PluginDocumentSettingPanel
name="ai-text-to-speech"
title={ __( 'Text to Speech', 'ai' ) }
className="ai-text-to-speech-panel"
>
<TextToSpeechPanel />
</PluginDocumentSettingPanel>
);
};

registerPlugin( 'ai-text-to-speech', {
render: TextToSpeechPlugin,
} );
18 changes: 18 additions & 0 deletions src/experiments/text-to-speech/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Types for the Text to Speech experiment.
*/

export interface TextToSpeechData {
enabled: boolean;
hasTtsSupport: boolean;
}

export interface TtsStatus {
status: 'idle' | 'pending' | 'processing' | 'complete' | 'error';
done: number;
total: number;
error: string;
audio_id: number;
audio_url: string;
display_audio: boolean;
}
Loading