diff --git a/package-lock.json b/package-lock.json index 9616023..5bcab06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "cloudflare-polly-proxy", "version": "0.0.0", + "dependencies": { + "aws4fetch": "^1.0.18" + }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.1.0", "vitest": "1.3.0", @@ -1000,6 +1003,11 @@ "node": "*" } }, + "node_modules/aws4fetch": { + "version": "1.0.18", + "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.18.tgz", + "integrity": "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ==" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3705,6 +3713,11 @@ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", "dev": true }, + "aws4fetch": { + "version": "1.0.18", + "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.18.tgz", + "integrity": "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ==" + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", diff --git a/package.json b/package.json index b705a1a..3dc3ae0 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,11 @@ "test": "vitest" }, "devDependencies": { - "wrangler": "^3.0.0", + "@cloudflare/vitest-pool-workers": "^0.1.0", "vitest": "1.3.0", - "@cloudflare/vitest-pool-workers": "^0.1.0" + "wrangler": "^3.0.0" + }, + "dependencies": { + "aws4fetch": "^1.0.18" } -} \ No newline at end of file +} diff --git a/src/index.js b/src/index.js index de66b62..14e7b54 100644 --- a/src/index.js +++ b/src/index.js @@ -1,15 +1,69 @@ +import jsmodule from '../static/twitch-polly.mjs' +import { AwsClient } from 'aws4fetch' + /** - * Welcome to Cloudflare Workers! This is your first worker. - * - * - Run `npm run dev` in your terminal to start a development server - * - Open a browser tab at http://localhost:8787/ to see your worker in action - * - Run `npm run deploy` to publish your worker - * - * Learn more at https://developers.cloudflare.com/workers/ + * create a Response object with control headers set + * @param {BodyInit} body + * @param {ResponseInit} init */ +function makeResponse(body=undefined,init=undefined){ + if(!init){ + init={} + } + if(!init.headers){ + init.headers={} + } + init.headers['access-control-allow-headers']='authorization,client-id' + init.headers['access-control-allow-origin']='*' + init.headers['access-control-allow-private-network']='true' + init.headers['cache-control']='none' + return new Response(body,init) +} + +function awsRequest(accessKeyId,secretAccessKey,host,endpoint,options){ + console.log(host+endpoint+options) + const aws=new AwsClient({ + accessKeyId:accessKeyId, + secretAccessKey:secretAccessKey, + }) + return aws.fetch(host+endpoint+options) +} export default { async fetch(request, env, ctx) { - return new Response('Hello World!'); + if(request.method==='OPTIONS'){ + return makeResponse() + } + const url=new URL(request.url) + if(url.pathname==='/twitch-polly.mjs'){ + return makeResponse(jsmodule,{ + headers:{ + 'content-type':'text/javascript' + } + }) + } + const validation=await fetch('https://id.twitch.tv/oauth2/validate', + {headers:request.headers}) + if(!validation.ok){ + return validation + } + if(url.pathname=='/describe_voices'){ + url.pathname='/v1/voices' + } + if(url.pathname=='/synthesize_speech'){ + url.pathname='/v1/speech' + } + if(url.searchParams.get('LanguageCode')==''){ + url.searchParams.delete('LanguageCode') + } + return makeResponse( + await awsRequest( + env.AWS_ACCESS_KEY_ID, + env.AWS_SECRET_ACCESS_KEY, + env.AWS_HOST, + url.pathname, + url.search + ).then(response=>response.blob()) + ) }, }; diff --git a/static/twitch-polly.mjs b/static/twitch-polly.mjs new file mode 100644 index 0000000..0ee776b --- /dev/null +++ b/static/twitch-polly.mjs @@ -0,0 +1,190 @@ +/** +Returns the list of voices that are available for use when requesting speech synthesis. +Each voice speaks a specified language, is either male or female, and is identified by an ID, which is the ASCII version of the voice name. + +When synthesizing speech ( SynthesizeSpeech ), you provide the voice ID for the voice you want from the list of voices returned by DescribeVoices. + +For example, you want your news reader application to read news in a specific language, but giving a user the option to choose the voice. +Using the DescribeVoices operation you can provide the user with a list of available voices to select from. + +You can optionally specify a language code to filter the available voices. +For example, if you specify en-US, the operation returns a list of all available US English voices. + +This operation requires permissions to perform the polly:DescribeVoices action. + +@param {string} Engine Specifies the engine ( standard or neural) used by Amazon Polly when processing input text for speech synthesis. +@param {string} LanguageCode The language identification tag (ISO 639 code for the language name-ISO 3166 country code) for filtering the list of voices returned. +If you don’t specify this optional parameter, all available voices are returned. +@param {boolean} IncludeAdditionalLanguageCodes Boolean value indicating whether to return any bilingual voices that use the specified language as an additional language. +For instance, if you request all languages that use US English (es-US), +and there is an Italian voice that speaks both Italian (it-IT) and US English, +that voice will be included if you specify yes but not if you specify no. +@param {dict} auth_headers your application's authorization headers used when making Twitch API requests +@returns {Promise} An array of dicts with the following properties: + +Gender (string) – Gender of the voice. + +Id (string) – Amazon Polly assigned voice ID. +This is the ID that you specify when calling the SynthesizeSpeech operation. + +LanguageCode (string) – Language code of the voice. + +LanguageName (string) – Human readable name of the language in English. + +Name (string) – Name of the voice (for example, Salli, Kendra, etc.). +This provides a human readable voice name that you might display in your application. + +AdditionalLanguageCodes (list) – Additional codes for languages available for the specified voice in addition to its default language. +For example, the default language for Aditi is Indian English (en-IN) because it was first used for that language. +Since Aditi is bilingual and fluent in both Indian English and Hindi, this parameter would show the code hi-IN. + +SupportedEngines (list) – Specifies which engines ( standard or neural) that are supported by a given voice. +*/ +export function DescribeVoices( + auth_headers, + Engine='standard', + LanguageCode='', + IncludeAdditionalLanguageCodes=true){ + const url=new URL('/v1/voices',import.meta.url) + url.search=new URLSearchParams({ + Engine:Engine, + LanguageCode:LanguageCode, + IncludeAdditionalLanguageCodes:IncludeAdditionalLanguageCodes + }) + return fetch(url,{headers:auth_headers}) + .then(response=>response.json()) + .then(json=>json.Voices) +} + +function debug_log(message){ + console.debug('synthesize_speech():',message) +} +/** +Synthesizes UTF-8 input, plain text or SSML, to a stream of bytes. +SSML input must be valid, well-formed SSML. +Some alphabets might not be available with all the voices (for example, Cyrillic might not be read at all by English voices) unless phoneme mapping is used. +@param {String} Text +Input text to synthesize. If you specify ssml as the TextType, follow the SSML format for the input text. + +Type: String + +Required: Yes +@param {String} VoiceId +Voice ID to use for the synthesis. You can get a list of available voice IDs by calling the DescribeVoices operation. + +Type: String + +Required: Yes +@param {String} TextType +Specifies whether the input text is plain text or SSML. The default value is plain text. + +Type: String + +Valid Values: ssml | text + +Required: No +@param {string} Engine +Specifies the engine (standard or neural) for Amazon Polly to use when processing input text for speech synthesis. + +When using NTTS-only voices such as Kevin (en-US), this parameter is required and must be set to neural. +If the engine is not specified, or is set to standard, this will result in an error. + +For standard voices, this is not required; the engine parameter defaults to standard. + +Type: String + +Valid Values: standard | neural + +Required: No +@param {string} LanguageCode +Optional language code for the Synthesize Speech request. +This is only necessary if using a bilingual voice, such as Aditi, which can be used for either Indian English (en-IN) or Hindi (hi-IN). + +If a bilingual voice is used and no language code is specified, Amazon Polly uses the default language of the bilingual voice. +The default language for any voice is the one returned by the DescribeVoices operation for the LanguageCode parameter. +For example, if no language code is specified, Aditi will use Indian English rather than Hindi. + +Type: String + +Required: No +@param {string} OutputFormat +The format in which the returned output will be encoded. +For audio stream, this will be mp3, ogg_vorbis, or pcm. For speech marks, this will be json. + +When pcm is used, the content returned is audio/pcm in a signed 16-bit, 1 channel (mono), little-endian format. + +Type: String + +Valid Values: json | mp3 | ogg_vorbis | pcm + +Required: No +@param {string} SampleRate +The audio frequency specified in Hz. + +The valid values for mp3 and ogg_vorbis are "8000", "16000", "22050", and "24000". The default value is "24000". + +Valid values for pcm are "8000" and "16000". + +Type: String + +Required: No +@param {dict} auth_headers your application's authorization headers used when making Twitch API requests +@returns {Promise} see OutputFormat parameter +*/ +export function SynthesizeSpeech( + auth_headers, + Text, + VoiceId, + TextType='text', + Engine='standard', + LanguageCode='', + OutputFormat='mp3', + SampleRate='24000'){ + console.debug('synthesize_speech():',Text) + if(TextType=='ssml'){ + const openSpeak=/^< *speak *>/ig + const closeSpeak=/< *\/ *speak *>$/ig + if(!openSpeak.test(Text)){ + Text=''+Text + } + if(!closeSpeak.test(Text)){ + Text+='' + } + // /(?<=< *)($1)(?= *>)/ig + const replacements={ + '':/< *\/ *(volume|rate|pitch|max-duration) *>/ig, + '':/< *\/ *interpret-as *>/ig, + '':/< *\/ *alias *>/ig, + '':/< *news *>/ig, + '':/< *\/ *news *>/ig, + 'effect=$1':/(?<=< *)(drc|whispered)(?= *>)/ig, + ')/ig, + 'effect vocal-tract-length':/(?<=< *)(vocal-tract-length)(?= *=)/ig, + 'amazon:$1':/(?<=<.*= *"? *)(VBD|VB|DT|IN|JJ|NN|DEFAULT|SENSE_1)(?=.*>)/ig, + 'amazon:$1':/(?<=<.*)(?)/ig, + } + for(const key in replacements){ + Text=Text.replaceAll(replacements[key],key) + } + console.debug(Text) + } + const url=new URL('/v1/speech',import.meta.url) + url.search=new URLSearchParams({ + Text:Text, + VoiceId:VoiceId, + TextType:TextType, + Engine:Engine, + LanguageCode:LanguageCode, + OutputFormat:OutputFormat, + SampleRate:SampleRate + }) + return fetch(url,{headers:auth_headers}) + } + +export default SynthesizeSpeech \ No newline at end of file diff --git a/wrangler.toml b/wrangler.toml index 0a15a4e..c66b4c9 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -3,106 +3,9 @@ name = "cloudflare-polly-proxy" main = "src/index.js" compatibility_date = "2024-06-05" compatibility_flags = ["nodejs_compat"] +rules = [ + { type = "Data", globs = ["../static/*"], fallthrough = true } +] -# Automatically place your workloads in an optimal location to minimize latency. -# If you are running back-end logic in a Worker, running it closer to your back-end infrastructure -# rather than the end user may result in better performance. -# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement -# [placement] -# mode = "smart" - -# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) -# Docs: -# - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables -# Note: Use secrets to store sensitive data. -# - https://developers.cloudflare.com/workers/configuration/secrets/ -# [vars] -# MY_VARIABLE = "production_value" - -# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai -# [ai] -# binding = "AI" - -# Bind an Analytics Engine dataset. Use Analytics Engine to write analytics within your Pages Function. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#analytics-engine-datasets -# [[analytics_engine_datasets]] -# binding = "MY_DATASET" - -# Bind a headless browser instance running on Cloudflare's global network. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#browser-rendering -# [browser] -# binding = "MY_BROWSER" - -# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases -# [[d1_databases]] -# binding = "MY_DB" -# database_name = "my-database" -# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - -# Bind a dispatch namespace. Use Workers for Platforms to deploy serverless functions programmatically on behalf of your customers. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms -# [[dispatch_namespaces]] -# binding = "MY_DISPATCHER" -# namespace = "my-namespace" - -# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. -# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects -# [[durable_objects.bindings]] -# name = "MY_DURABLE_OBJECT" -# class_name = "MyDurableObject" - -# Durable Object migrations. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations -# [[migrations]] -# tag = "v1" -# new_classes = ["MyDurableObject"] - -# Bind a Hyperdrive configuration. Use to accelerate access to your existing databases from Cloudflare Workers. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#hyperdrive -# [[hyperdrive]] -# binding = "MY_HYPERDRIVE" -# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - -# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces -# [[kv_namespaces]] -# binding = "MY_KV_NAMESPACE" -# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - -# Bind an mTLS certificate. Use to present a client certificate when communicating with another service. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#mtls-certificates -# [[mtls_certificates]] -# binding = "MY_CERTIFICATE" -# certificate_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - -# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues -# [[queues.producers]] -# binding = "MY_QUEUE" -# queue = "my-queue" - -# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues -# [[queues.consumers]] -# queue = "my-queue" - -# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#r2-buckets -# [[r2_buckets]] -# binding = "MY_BUCKET" -# bucket_name = "my-bucket" - -# Bind another Worker service. Use this binding to call another Worker without network overhead. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings -# [[services]] -# binding = "MY_SERVICE" -# service = "my-service" - -# Bind a Vectorize index. Use to store and query vector embeddings for semantic search, classification and other vector search use-cases. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#vectorize-indexes -# [[vectorize]] -# binding = "MY_INDEX" -# index_name = "my-index" +[vars] +AWS_HOST = 'https://polly.us-east-1.amazonaws.com' \ No newline at end of file