@creativeorange/azure-text-to-speech
v3.0.2
Published
Browser package for Azure Cognitive Services Speech with **temporary authorization tokens**. Permanent Azure subscription keys never enter frontend JavaScript.
Downloads
1,069
Readme
Azure Text to Speech / Speech to Text
Browser package for Azure Cognitive Services Speech with temporary authorization tokens. Permanent Azure subscription keys never enter frontend JavaScript.
Package: @creativeorange/azure-text-to-speech
Current major version: 3.0.0
Why version 3 exists
In version 2, constructors accepted a permanent Azure subscription key and used fromSubscription. That forced the real API key into browser bundles, DevTools, and memory.
Version 3 removes that pattern completely. The permanent key stays on your server. The browser only receives short-lived Azure authorization tokens via your own backend.
Architecture
Browser
→ your backend token endpoint
→ Azure issueToken endpoint
← temporary Azure token + region
→ Azure Speech SDK (fromAuthorizationToken)Supported authentication options:
tokenEndpoint— simple GET endpoint (Craft, classic sites)getAuthorizationToken— custom async provider
There is no option to pass a permanent subscription key.
The Azure Speech SDK is bundled into the published dist files, so consumers do not need to install microsoft-cognitiveservices-speech-sdk separately. That dependency remains a devDependency of this package.
Install
npm install @creativeorange/azure-text-to-speechTypeScript declarations are published at dist/main.d.ts.
import {
TextToSpeech,
SpeechToText,
type TextToSpeechOptions,
type SpeechToTextOptions,
type SpeechAuthorization,
} from '@creativeorange/azure-text-to-speech';Always import from the package root. Do not import from /dist/... paths.
Public API
Text to Speech with tokenEndpoint
const textToSpeech = new TextToSpeech({
tokenEndpoint: '/actions/azure-speech/token',
voice: 'nl-NL-FennaNeural',
});
await textToSpeech.start();The Speech region comes from the token endpoint response, not from frontend configuration. That prevents mismatches between frontend, backend and Azure resource region.
Text to Speech with a custom provider
const textToSpeech = new TextToSpeech({
voice: 'nl-NL-FennaNeural',
getAuthorizationToken: async () => {
const response = await fetch('/api/azure-speech/token', {
credentials: 'same-origin',
cache: 'no-store',
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error('Could not obtain Azure Speech token');
}
return response.json();
},
});
await textToSpeech.start();Speech to Text
const speechToText = new SpeechToText({
tokenEndpoint: '/actions/azure-speech/token',
sourceLanguage: 'nl-NL',
targetLanguage: 'nl',
});
await speechToText.start();Continuous recognition can outlive a single Azure token. After recognition starts successfully, Speech-to-Text refreshes the active recognizer token about every eight minutes (tokenLifetimeMs) by setting recognizer.authorizationToken.
Token endpoint response
{
"token": "temporary-azure-token",
"region": "westeurope"
}Options
type SpeechAuthorization = {
token: string;
region: string;
};
type SpeechAuthenticationOptions = {
tokenEndpoint?: string;
getAuthorizationToken?: () => Promise<SpeechAuthorization>;
tokenRequestOptions?: {
headers?: Record<string, string>;
credentials?: RequestCredentials;
cache?: RequestCache; // default: 'no-store'
};
tokenLifetimeMs?: number; // default: 8 minutes
};
type TextToSpeechOptions = SpeechAuthenticationOptions & {
voice: string;
rate?: number;
pitch?: number;
url?: string; // lexicon URL
};
type SpeechToTextOptions = SpeechAuthenticationOptions & {
sourceLanguage: string;
targetLanguage?: string;
};Provide exactly one of tokenEndpoint or getAuthorizationToken.
Token caching and transport
- Tokens are cached in memory and reused while valid.
- Tokens refresh after about 8 minutes by default.
- Parallel callers share one in-flight request.
tokenEndpointrequests usecredentials: 'same-origin'andcache: 'no-store'by default.- Tokens are never logged and never included in error events.
Browser events
TTS:
COAzureTTSStartedPlayingCOAzureTTSFinishedPlayingCOAzureTTSStoppedPlayingCOAzureTTSPausedPlayingCOAzureTTSResumedPlayingCOAzureTTSError→{ detail: { error: { message, code? } } }
STT:
COAzureSTTStartedRecordingCOAzureSTTStoppedRecordingCOAzureSTTError→{ detail: { error: { message, code? } } }
Migrating from v2 to v3
Old unsafe usage
const textToSpeech = new TextToSpeech(
window.azureSpeechKey,
'westeurope',
'nl-NL-FennaNeural',
0,
0
);Remove Twig/Vite/window exports of the subscription key.
New safe usage
const textToSpeech = new TextToSpeech({
tokenEndpoint: '/actions/azure-speech/token',
voice: 'nl-NL-FennaNeural',
rate: 0,
pitch: 0,
});Migration checklist
- Update the package to
3.x. - Store
AZURE_SPEECH_KEYandAZURE_SPEECH_REGIONserver-side only. - Add a backend token endpoint.
- Replace the old constructor with the options object.
- Remove every frontend variable that contained the subscription key.
- Rebuild the frontend.
- Confirm in DevTools that the permanent key no longer appears.
- Test TTS, STT, highlighting, pause/resume, chained playback and prefetching.
- Rotate the previously exposed Azure subscription key.
Craft CMS integration
.env
AZURE_SPEECH_KEY="replace-with-secret-key"
AZURE_SPEECH_REGION="westeurope"Never export these values to Twig globals, Vite define, or frontend env files.
Module registration (config/app.php)
<?php
return [
'modules' => [
'azure-speech' => [
'class' => \modules\azurespeech\Module::class,
],
],
'bootstrap' => [
'azure-speech',
],
];Craft action routing uses:
- module ID:
azure-speech - controller ID:
token(TokenController) - action:
actionIndex
Resulting URL:
/actions/azure-speech/tokenEnsure the module registers its controller namespace, for example in modules/azurespeech/Module.php:
public function init(): void
{
parent::init();
// Controllers live in modules\azurespeech\controllers
}Token controller for Craft 4 / Craft 5
<?php
namespace modules\azurespeech\controllers;
use Craft;
use craft\web\Controller;
use yii\web\Response;
class TokenController extends Controller
{
// Craft 4 and Craft 5
protected array|bool|int $allowAnonymous = true;
public function actionIndex(): Response
{
$key = Craft::parseEnv('$AZURE_SPEECH_KEY');
$region = Craft::parseEnv('$AZURE_SPEECH_REGION');
if (!$key || !$region) {
return $this->asFailure(
'Azure Speech configuration is missing.'
);
}
$client = Craft::createGuzzleClient();
try {
$azureResponse = $client->post(
"https://{$region}.api.cognitive.microsoft.com/sts/v1.0/issueToken",
[
'headers' => [
'Ocp-Apim-Subscription-Key' => $key,
],
'timeout' => 10,
]
);
} catch (\Throwable $exception) {
Craft::error(
'Could not retrieve an Azure Speech token.',
__METHOD__
);
return $this->asFailure(
'Speech authorization is temporarily unavailable.'
);
}
$craftResponse = $this->asJson([
'token' => (string) $azureResponse->getBody(),
'region' => $region,
]);
$craftResponse->getHeaders()->set(
'Cache-Control',
'private, no-store, max-age=0'
);
$craftResponse->getHeaders()->set(
'Pragma',
'no-cache'
);
return $craftResponse;
}
}Token controller property for Craft 3 / older PHP
// Craft 3 or older PHP versions without union property types
protected $allowAnonymous = true;Use the property form that matches your Craft and PHP version. Do not log tokens or exception details to the browser.
Frontend initialization
import {TextToSpeech, SpeechToText} from '@creativeorange/azure-text-to-speech';
const textToSpeech = new TextToSpeech({
tokenEndpoint: '/actions/azure-speech/token',
voice: 'nl-NL-FennaNeural',
});
textToSpeech.start().catch((error) => {
console.error('Could not initialize text to speech.', error);
});
const speechToText = new SpeechToText({
tokenEndpoint: '/actions/azure-speech/token',
sourceLanguage: 'nl-NL',
targetLanguage: 'nl',
});
speechToText.start().catch((error) => {
console.error('Could not initialize speech to text.', error);
});Protecting the Craft token endpoint
A temporary token may appear in the browser. That does not mean the endpoint can be public and unlimited.
Recommended controls:
- Craft rate limiting and/or webserver/proxy rate limiting
- Cloudflare rate limiting when available
- Authentication when TTS/STT is not meant for anonymous visitors
- HTTPS only
- Short server-side timeouts
- Azure budget and usage alerts
- Azure resource networking restrictions when infrastructure allows it
- Never put the permanent key in responses or logs
- Return
Cache-Control: private, no-store, max-age=0
CORS alone is not security. Endpoints can be called outside a browser.
Laravel token endpoint
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
Route::get('/api/azure-speech/token', function () {
$region = config('services.azure_speech.region');
$azureResponse = Http::timeout(10)
->withHeaders([
'Ocp-Apim-Subscription-Key' =>
config('services.azure_speech.key'),
])
->post(
"https://{$region}.api.cognitive.microsoft.com/sts/v1.0/issueToken"
);
abort_unless($azureResponse->successful(), 502);
return response()
->json([
'token' => $azureResponse->body(),
'region' => $region,
])
->header(
'Cache-Control',
'private, no-store, max-age=0'
)
->header('Pragma', 'no-cache');
})->middleware('throttle:20,1');Put this route behind authentication whenever the feature is not public.
Existing browser features
Version 3 keeps the existing client behaviour from the development branch:
- play / pause / resume / stop
- word-boundary highlighting
- chained playback (
co-tts.next) - audio prefetching
- lexicons via
url - voice / rate / pitch setters
- existing custom browser events
Development
npm ci
npm run lint
npm test
npm run build
npm run typecheckSecurity sanity checks:
grep -R "fromSubscription" src
grep -R "subscriptionKey" src
grep -R "Ocp-Apim-Subscription-Key" src
grep -R "@creativeorange/azure-text-to-speech/dist" .These should return no matches in package source or active examples.
License
MIT
