Speech-to-Text Plugin for NativePHP Mobile#
On-device dictation / speech recognition for NativePHP Mobile on iOS and Android.
Used in: PARqore's own apps New Life Daily (dictating journal drafts) and Hard Moments (dictating notes).
See it in action#
Vipul Walia (vipertecpro) built a free, open-source demo app and a hands-on walkthrough of this plugin, together with PARqore's Text to Speech, Native Share and Force Update:
- Video: NativePHP Mobile: Speech to Text, Text to Speech, Share & Force Update
- Demo app on GitHub
- Hands-on blog post
Overview#
PHP starts a listen session; the OS speech recognizer turns the microphone into text and delivers it as events. listen() does not return the transcript synchronously.
Call SpeechToText::listen() from a screen or component (for example a mic button). The native layer runs SFSpeechRecognizer on iOS and SpeechRecognizer / RecognizerIntent on Android, then fires SpeechPartial, SpeechResult, SpeechFailed, or PermissionDenied. stop() ends the session and keeps the final text. cancel() ends it and discards.
This is not the official microphone plugin: that records an audio file. This plugin returns text and does not save recordings.
This is not vikas5914/fluidaudio: that package is iOS-only Apple Core ML. This plugin uses the platform speech APIs on both iOS and Android. No extra ML Kit or CocoaPods dependency.
Installation#
composer require partek/speech-to-text
Don't forget to register the plugin:
php artisan native:plugin:register partek/speech-to-text
Usage#
PHP (Super Native)#
use Native\Mobile\Attributes\On;use PARTek\SpeechToText\Events\PermissionDenied;use PARTek\SpeechToText\Events\SpeechFailed;use PARTek\SpeechToText\Events\SpeechPartial;use PARTek\SpeechToText\Events\SpeechResult;use PARTek\SpeechToText\Facades\SpeechToText; class DictateScreen extends NativeComponent{ public string $transcript = ''; public bool $listening = false; public function startDictation(): void { $this->listening = SpeechToText::listen(); } public function stopDictation(): void { SpeechToText::stop(); $this->listening = false; } public function cancelDictation(): void { SpeechToText::cancel(); $this->listening = false; $this->transcript = ''; } #[On(SpeechPartial::class)] public function onPartial(string $text): void { $this->transcript = $text; } #[On(SpeechResult::class)] public function onResult(string $text, bool $isFinal): void { $this->transcript = $text; if ($isFinal) { $this->listening = false; } } #[On(SpeechFailed::class)] public function onFailed(string $message): void { $this->listening = false; } #[On(PermissionDenied::class)] public function onPermissionDenied(?string $message = null): void { $this->listening = false; }}
Optional locale (BCP 47) and partial results:
SpeechToText::listen('en-US');SpeechToText::listen('en-US', false);
JavaScript (Vue/React/Inertia)#
import { listen, stop, cancel } from 'partek-speech-to-text'; await listen();await listen('en-US', true);await stop();await cancel();
The CSRF token is read from the page <meta name="csrf-token">. Bridge errors throw.
Events#
SpeechPartial#
Fired while the user is speaking when partial is true.
| Payload | Type | Description |
|---|---|---|
text |
string | Current hypothesis |
SpeechResult#
Fired when the session produces a transcript to keep (typically after stop() or end of speech).
| Payload | Type | Description |
|---|---|---|
text |
string | Recognized text |
isFinal |
bool | true when this is the session's final result |
SpeechFailed#
Fired when recognition cannot start or fails.
| Payload | Type | Description |
|---|---|---|
message |
string | Reason |
PermissionDenied#
Fired when speech recognition or microphone permission is denied. The plugin does not continue silently.
| Payload | Type | Description |
|---|---|---|
message |
string|null | Optional reason |
Methods#
listen(?string $locale = null, bool $partial = true): bool#
Starts a listen session. Returns whether the native layer accepted the request. The transcript is not in the return value.
| Parameter | Type | Description |
|---|---|---|
locale |
string|null | BCP 47 tag such as en-US. null or empty uses the device locale. |
partial |
bool | When true, dispatch SpeechPartial while the user is speaking. Default true. |
stop(): bool#
Stops the session and keeps the final transcript (SpeechResult).
cancel(): bool#
Stops the session and discards the transcript. No SpeechResult is dispatched.
Platform behavior#
On-device vs network#
- iOS: Uses
SFSpeechRecognizer. When the locale supports on-device recognition (supportsOnDeviceRecognition), the request requires on-device. If that locale is not available on-device, Apple's recognizer may fall back to network (audio sent to Apple). - Android: Uses
SpeechRecognizerwith aRecognizerIntent. When on-device recognition is available (API 31+), the plugin preferscreateOnDeviceSpeechRecognizerandEXTRA_PREFER_OFFLINE. Otherwise the system recognizer may use the network.
Permission prompts#
- Android: Runtime
RECORD_AUDIO. Denied →PermissionDenied. - iOS: Speech recognition (
NSSpeechRecognitionUsageDescription) and microphone (NSMicrophoneUsageDescription). Either denied →PermissionDenied.
Android intent vs iOS silent#
- iOS: In-app and silent. There is no system dictation sheet.
- Android: In-app
SpeechRecognizerdriven by aRecognizerIntent(not the full Google voice-search activity).stop()/cancel()stay in your screen. Some devices still show a system listening indicator.
Silence timeout#
- iOS:
SFSpeechRecognizerkeeps listening until you callstop()orcancel()- no automatic cutoff on a pause. - Android:
RecognizerIntentends the session after silence. This plugin setsEXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLISandEXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLISto 3000ms (up from the OS default of ~1000ms), confirmed on-device (Galaxy A54) to fix a real cutoff mid-sentence during a normal pause for breath. Some OEM builds may still apply their own shorter limit on top of this.
Testing#
Test helpers#
On NativePHP Mobile 4.0 or later, the plugin registers these helpers on Native::fakeBridge() and on the Native::test() harness whenever the app runs under a test runner (APP_ENV=testing). Production boots never register them, and there is nothing extra to install. The screen under test calls the real facade, the helpers answer the bridge the way the device does, and they deliver the recognizer's events.
use App\NativeComponents\DictateScreen;use Native\Mobile\Testing\Native; it('shows what the user said', function () { Native::fakeBridge()->withSpeechRecognition(); Native::test(DictateScreen::class) ->tap('Dictate') ->assertListeningForSpeech() ->hearPartialSpeech('Buy milk') ->assertSee('Buy milk') ->hearSpeech('Buy milk and eggs') ->assertSee('Buy milk and eggs') ->assertSet('listening', false);});
Script the bridge first. With nothing scripted, the fake bridge answers null and listen(), stop() and cancel() return false. The bridge assertions would still pass while your screen runs its failure branch.
| Helper | Called on | What it does |
|---|---|---|
withSpeechRecognition() |
bridge | listen(), stop() and cancel() are accepted. All three answer {accepted: true}, as the native side does. |
assertListeningForSpeech(?string $locale = null, ?bool $partial = null) |
bridge | listen() reached the bridge: any session, or one with this locale and/or partial flag. listen() trims the locale and sends '' for the device locale, so pass '' to assert that. Any matching call passes, not only the last. |
assertSpeechListeningStopped() |
bridge | stop() reached the bridge. |
assertSpeechListeningCancelled() |
bridge | cancel() reached the bridge. |
hearPartialSpeech(string $text) |
harness | Delivers SpeechPartial, as while the user is still speaking. |
hearSpeech(string $text, bool $isFinal = true) |
harness | Delivers SpeechResult. Both recognizers always send isFinal: true; pass false to test an interim branch. |
failSpeechRecognition(string $message = 'Recognition failed') |
harness | Delivers SpeechFailed. |
denySpeechPermission(string $message = 'Microphone permission was denied.') |
harness | Delivers PermissionDenied. |
Bridge helpers work on Native::fakeBridge() or straight off the harness. Called off the harness, they return the harness, so the chain continues.
- No session ids. A listen session has no id, so the harness helpers deliver their event to the screen under test straight away. They don't check that
listen()ran first; useassertListeningForSpeech()for that. - Partial results. The device only sends
SpeechPartialwhenlisten()was called withpartial: true(the default).hearPartialSpeech()doesn't check this. - Messages. Both platforms send
Microphone permission was denied.when the microphone is denied, which is thedenySpeechPermission()default. iOS also sendsSpeech recognition permission was denied.ThefailSpeechRecognition()default is a placeholder, because no failure message is shared by both platforms. iOS sendsSpeech recognition is not available for this locale.,Speech recognition is not available right now.,Microphone input is not available.or the system error's description. Android sendsNo speech recognized.,No speech detected.,Speech recognition is not available on this device.,Network speech recognition failed.and other recognizer errors. Pass the message your screen handles. - With your own
SpeechToTextbinding. If a test binds its ownnew SpeechToText(fn (...) => ...)double into the container, calls go to that callable and never reach the bridge, so the bridge helpers see nothing. The harness helpers still deliver events.
This package's tests#
The package's own suite covers facade listen() / stop() / cancel() argument shaping, the empty-locale default, the event classes, and the test helpers above, driven through a small dictation screen. It does not play device audio.
composer test