Building It

Building a Voice-Driven Chatbot Front End

⏱ 17 min

Every piece exists now: Lesson 2’s mic button transcribes speech into the widget’s input field, Lesson 4’s local Piper service turns a reply back into audio. This lesson connects both to Course 11’s actual chat widget, the one with a toggle button, message panel, and REST-backed sendMessage(), so it becomes something a visitor can speak to and hear from, without touching anything about how that widget already talks to wp_ai_client_prompt() on the backend.

What you'll learn in this lesson
Auto-submitting a transcribed message
Rather than leaving the visitor to press send after speaking.
Playing a spoken reply automatically
Fetching Lesson 4's TTS audio for the assistant's response and playing it without a click.
A visible listening and speaking state
So a visitor always knows whether the widget is currently hearing them or about to talk.
Letting text-only visitors opt out entirely
Voice is additive, the widget still works exactly as Course 11 built it with voice off.
Prerequisites

Course 11’s Building the Chat Widget UI and Wiring the Widget to a WordPress REST Endpoint, plus this course’s Lesson 2 (browser speech-to-text) and Lesson 4 (local Piper TTS).

Step 1: Auto-submit after a transcript arrives

Lesson 2 filled the input field but left the send button for the visitor to press. For a voice-first flow, submitting automatically once recognition ends is what makes it feel like a conversation instead of a two-step chore:

// File: assets/js/voice-input.js (extending Lesson 2's recognition.addEventListener('result', ...))
recognition.addEventListener( 'result', function ( event ) {
	var transcript = event.results[ 0 ][ 0 ].transcript;
	input.value = transcript;

	var form = document.getElementById( 'site-chat-form' );
	form.dispatchEvent( new Event( 'submit', { cancelable: true } ) );
} );

Course 11’s existing form.addEventListener( 'submit', ... ) handler runs exactly as it already did, appending the visitor’s bubble and calling sendMessage(), it has no idea whether the text arrived by typing or by voice, which is exactly the point.

Step 2: Speak the assistant’s reply back

Course 11’s sendMessage() eventually calls appendMessage( 'assistant', text ) once a real reply comes back from the REST endpoint. Hook into that same point to also speak it, using Lesson 4’s local Piper service:

// File: assets/js/voice-output.js
function speakReply( text ) {
	if ( ! window.siteChatWidget || ! window.siteChatWidget.voiceEnabled ) {
		return;
	}

	fetch( window.siteChatWidget.ttsUrl, {
		method: 'POST',
		headers: {
			'Content-Type': 'application/json',
			'X-WP-Nonce': window.siteChatWidget.nonce,
		},
		body: JSON.stringify( { text: text } ),
	} )
		.then( function ( response ) {
			return response.json();
		} )
		.then( function ( data ) {
			if ( data.audio_url ) {
				new Audio( data.audio_url ).play();
			}
		} )
		.catch( function ( error ) {
			console.error( 'Text-to-speech playback failed:', error );
		} );
}
// File: inc/rest-tts-endpoint.php
add_action( 'rest_api_init', function () {
	register_rest_route( 'my-plugin/v1', '/speak', array(
		'methods'             => 'POST',
		'callback'            => 'my_plugin_handle_speak_request',
		'permission_callback' => '__return_true',
	) );
} );

function my_plugin_handle_speak_request( WP_REST_Request $request ) {
	$text = sanitize_textarea_field( $request->get_param( 'text' ) );

	if ( ! $text ) {
		return new WP_Error( 'missing_text', 'No text provided.', array( 'status' => 400 ) );
	}

	$url = my_plugin_speak_text( $text ); // Lesson 4's function

	if ( is_wp_error( $url ) ) {
		return $url;
	}

	return array( 'audio_url' => $url );
}

Calling speakReply() right where Course 11’s appendMessage( 'assistant', text ) already runs is the one edit that ties this lesson into that widget’s existing code, no restructuring of the message flow required.

A browser-only fallback needs no server round trip at all

If Lesson 4’s local Piper service isn’t running, or you’d rather start simpler, the Web Speech API’s own SpeechSynthesis interface can speak the same reply with zero backend call: new SpeechSynthesisUtterance( text ) passed to window.speechSynthesis.speak(). It’s a reasonable default to ship first, and to fall back to automatically if window.siteChatWidget.ttsUrl fails.

Step 3: A visible state for listening and speaking

States a visitor should always be able to see
1
Idle
Mic button in its normal state, nothing recording or playing.
2
Listening
Lesson 2's .is-listening class, a visible pulse or color change on the mic button while recognition.start() is active.
3
Speaking
A small indicator while the Audio object from Step 2 is playing, so a visitor doesn't wonder whether the widget is still "thinking."
// File: assets/js/voice-output.js (continued)
function speakReply( text ) {
	// ...fetch as above, then:
	var audio = new Audio( data.audio_url );
	document.getElementById( 'site-chat-widget' ).classList.add( 'is-speaking' );
	audio.addEventListener( 'ended', function () {
		document.getElementById( 'site-chat-widget' ).classList.remove( 'is-speaking' );
	} );
	audio.play();
}

Step 4: Let voice be opt-in, not a replacement

// File: assets/js/chat-widget.js (a small addition to Course 11's markup)
'<label class="site-chat-voice-toggle">' +
'  <input type="checkbox" id="site-chat-voice-enabled" />' +
'  Enable voice replies' +
'</label>'
document.getElementById( 'site-chat-voice-enabled' ).addEventListener( 'change', function ( e ) {
	window.siteChatWidget.voiceEnabled = e.target.checked;
} );

Text-only visitors, and anyone who simply prefers reading over audio playing on a page, should be able to turn this off with one click, speakReply()’s own guard clause at the top already checks this flag before doing anything.

Autoplay policies can block audio.play() silently

Browsers restrict autoplaying audio without a preceding user gesture. Since a spoken reply here follows the visitor’s own click or voice input, that gesture requirement is usually satisfied, but test this in each browser you support, a silently-blocked audio.play() with no visible error is a real, easy-to-miss failure mode.

Test it: a full spoken round trip

Click the mic, ask a real question out loud, and confirm three things happen in order: the transcript appears in the input and submits itself, the assistant’s text reply appears in the message panel exactly as Course 11 built it, and, with the voice toggle on, you actually hear that reply spoken back through Lesson 4’s Piper service or the SpeechSynthesis fallback.

Recap

Nothing about Course 11’s chat widget changed structurally. Lesson 2’s transcript now auto-submits through the widget’s existing form handler, and a new speakReply() call, hooked in exactly where the widget already appends an assistant message, plays Lesson 4’s synthesized audio back. A visible listening and speaking state, and a one-click way to turn voice off entirely, keep this honest about being an addition to a working text widget, not a replacement for it.

Resources & further reading

← Text-to-Speech With Piper Voice Search Over Your Content →