├── data └── files_go_here ├── .github └── FUNDING.yml ├── img ├── api_key.png ├── worker.png ├── add_worker.png ├── helloworld.png ├── worker_name.png └── yomitan_settings.png ├── .dev.vars.example ├── .gitignore ├── .prettierrc ├── src ├── lib │ ├── middleware.ts │ ├── keyVerification.ts │ ├── fetchAudioDB.ts │ ├── yomitanResponse.ts │ ├── awsPolly.ts │ ├── logger.ts │ ├── queryUtils.ts │ ├── queryAudioDB.ts │ ├── ttsUtils.ts │ └── utils.ts ├── index.ts └── routes │ └── audio.ts ├── wrangler.toml.example ├── package.json ├── tsconfig.json ├── scripts └── upload-to-r2.sh └── README.md /data/files_go_here: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: Quizmaster 2 | -------------------------------------------------------------------------------- /img/api_key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/friedrich-de/yomitan-ultimate-audio/HEAD/img/api_key.png -------------------------------------------------------------------------------- /img/worker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/friedrich-de/yomitan-ultimate-audio/HEAD/img/worker.png -------------------------------------------------------------------------------- /img/add_worker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/friedrich-de/yomitan-ultimate-audio/HEAD/img/add_worker.png -------------------------------------------------------------------------------- /img/helloworld.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/friedrich-de/yomitan-ultimate-audio/HEAD/img/helloworld.png -------------------------------------------------------------------------------- /img/worker_name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/friedrich-de/yomitan-ultimate-audio/HEAD/img/worker_name.png -------------------------------------------------------------------------------- /img/yomitan_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/friedrich-de/yomitan-ultimate-audio/HEAD/img/yomitan_settings.png -------------------------------------------------------------------------------- /.dev.vars.example: -------------------------------------------------------------------------------- 1 | API_KEYS="m9NixGU7qtWv4SYr" 2 | AWS_ACCESS_KEY_ID="AWS_ACCESS_KEY_ID_HERE" 3 | AWS_SECRET_ACCESS_KEY="AWS_SECRET_ACCESS_KEY_HERE" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .wrangler 3 | .dev.vars 4 | bundled 5 | node_modules 6 | wrangler.toml 7 | data/*files 8 | *txt 9 | *zip 10 | *sql -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "singleQuote": true, 6 | "printWidth": 140, 7 | "trailingComma": "es5" 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/middleware.ts: -------------------------------------------------------------------------------- 1 | import { IRequest } from 'itty-router'; 2 | 3 | export const withApiKey = async (request: IRequest, env: Env, context: ExecutionContext) => { 4 | if (!env.AUTHENTICATION_ENABLED) { 5 | return; 6 | } 7 | const apiKey = request.query?.apiKey; 8 | request.apiKey = apiKey; 9 | }; 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { AutoRouter, error, IRequest } from 'itty-router'; 2 | import { router as audioRouter } from './routes/audio'; 3 | import { errorHandler } from './lib/logger'; 4 | 5 | const router = AutoRouter({ catch: errorHandler }); 6 | 7 | router 8 | .get('/audio/*', audioRouter.fetch) 9 | .all('*', () => error(400)); 10 | 11 | export default router; 12 | -------------------------------------------------------------------------------- /wrangler.toml.example: -------------------------------------------------------------------------------- 1 | name = "yomitan-audio-worker" 2 | main = "src/index.ts" 3 | compatibility_date = "2025-03-21" 4 | 5 | [vars] 6 | AUTHENTICATION_ENABLED=true 7 | AWS_POLLY_ENABLED=true 8 | 9 | [[d1_databases]] 10 | binding = "yomitan_audio_d1_db" 11 | database_name = "yomitan-audio-db" 12 | database_id = "YOUR_DATABASE_ID_HERE" 13 | 14 | [[r2_buckets]] 15 | binding = "yomitan_audio_r2_bucket" 16 | bucket_name = "yomitan-audio-bucket" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yomitan-audio-worker", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "deploy": "wrangler deploy", 7 | "dev": "wrangler dev", 8 | "start": "wrangler dev", 9 | "cf-typegen": "wrangler types" 10 | }, 11 | "devDependencies": { 12 | "@cloudflare/workers-types": "^4.20250409.0", 13 | "prettier": "^3.5.3", 14 | "typescript": "^5.5.2", 15 | "wrangler": "^4.7.0" 16 | }, 17 | "dependencies": { 18 | "aws4fetch": "^1.0.20", 19 | "itty-fetcher": "^0.9.4", 20 | "itty-router": "^5.0.18" 21 | } 22 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | "target": "es2021", 5 | "lib": [ 6 | "es2021", 7 | "WebWorker" 8 | ], 9 | "module": "es2022", 10 | "moduleResolution": "Bundler", 11 | "noEmit": true, 12 | "isolatedModules": true, 13 | "allowSyntheticDefaultImports": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "skipLibCheck": true 17 | }, 18 | "include": [ 19 | "worker-configuration.d.ts", 20 | "src/**/*.ts" 21 | ] 22 | } -------------------------------------------------------------------------------- /src/lib/keyVerification.ts: -------------------------------------------------------------------------------- 1 | import { IRequest, StatusError } from 'itty-router'; 2 | 3 | export async function verifyApiKey(request: IRequest, env: Env) { 4 | if (!env.AUTHENTICATION_ENABLED) { 5 | return; 6 | } 7 | 8 | const apiKey = request.query?.apiKey; 9 | if (!apiKey) { 10 | throw new StatusError(400, 'Missing API key'); 11 | } 12 | 13 | if (Array.isArray(apiKey)) { 14 | throw new StatusError(400, 'API key provided in unexpected format.'); 15 | } 16 | 17 | const validApi_keys = env.API_KEYS.split(','); 18 | 19 | if (!validApi_keys.includes(apiKey)) { 20 | throw new StatusError(403, 'Invalid API key'); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/fetchAudioDB.ts: -------------------------------------------------------------------------------- 1 | import { StatusError } from 'itty-router'; 2 | 3 | export async function fetchAudioDB(source: string, file: string, env: Env): Promise { 4 | const key = `${source}_files/${file}`; 5 | const R2Response = await env.yomitan_audio_r2_bucket.get(key); 6 | 7 | if (!R2Response) { 8 | throw new StatusError(404, 'File not found'); 9 | } 10 | 11 | const mp3file = await R2Response.blob(); 12 | 13 | return mp3file; 14 | } 15 | 16 | export async function fetchAudioTTS(tts_identifier: string, env: Env): Promise { 17 | const key = `tts_files/${tts_identifier}.mp3`; 18 | const object = await env.yomitan_audio_r2_bucket.get(key); 19 | 20 | if (!object) { 21 | return null; 22 | } 23 | 24 | const mp3file = await object.blob(); 25 | 26 | return mp3file; 27 | } 28 | 29 | export async function saveAudioTTS(tts_identifier: string, mp3: Blob, env: Env): Promise { 30 | const key = `tts_files/${tts_identifier}.mp3`; 31 | await env.yomitan_audio_r2_bucket.put(key, mp3); 32 | return true; 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/yomitanResponse.ts: -------------------------------------------------------------------------------- 1 | import { IRequest } from 'itty-router'; 2 | import type { AudioEntry } from './queryAudioDB'; 3 | 4 | export interface YomitanResponse { 5 | type: 'audioSourceList'; 6 | audioSources: YomitanAudioSource[]; 7 | } 8 | 9 | export interface YomitanAudioSource { 10 | name: string; 11 | url: string; 12 | } 13 | 14 | export async function generateYomitanResponseObject( 15 | entries: AudioEntry[], 16 | ttsEntries: YomitanAudioSource[], 17 | request: IRequest, 18 | env: Env 19 | ): Promise { 20 | const audioSources = entries.map((entry) => { 21 | let audioUrl; 22 | const url = new URL(request.url); 23 | 24 | if (entry.file.includes('/')) { 25 | const parts = entry.file.split('/'); 26 | const folder = encodeURIComponent(parts[0]); 27 | const file = encodeURIComponent(parts[1]); 28 | audioUrl = new URL(`/audio/get/${entry.source}/${folder}/${file}`, url.origin); 29 | } else { 30 | const file = encodeURIComponent(entry.file); 31 | audioUrl = new URL(`/audio/get/${entry.source}/${file}`, url.origin); 32 | } 33 | 34 | if (env.AUTHENTICATION_ENABLED) { 35 | audioUrl.searchParams.set('apiKey', request.apiKey); 36 | } 37 | 38 | return { 39 | name: entry.display, 40 | url: audioUrl.toString(), 41 | }; 42 | }); 43 | 44 | return { 45 | type: 'audioSourceList', 46 | audioSources: [...audioSources, ...ttsEntries], 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/lib/awsPolly.ts: -------------------------------------------------------------------------------- 1 | import { AwsClient } from 'aws4fetch'; 2 | import { StatusError } from 'itty-router'; 3 | import { log } from './logger'; 4 | 5 | enum TextTypes { 6 | SSML = 'ssml', 7 | TEXT = 'text', 8 | } 9 | 10 | export async function generateTTSAudio(term: string, reading: string, pitch: string, env: Env): Promise { 11 | const accessKeyId = env.AWS_ACCESS_KEY_ID; 12 | const secretAccessKey = env.AWS_SECRET_ACCESS_KEY; 13 | const region = 'eu-central-1'; 14 | 15 | let text_type: TextTypes = TextTypes.TEXT; 16 | let text = term; 17 | 18 | if (term && pitch) { 19 | text_type = TextTypes.SSML; 20 | text = `${term}`; 21 | } else if (term == reading) { 22 | text_type = TextTypes.TEXT; 23 | text = term; 24 | } else if (term && reading) { 25 | text_type = TextTypes.SSML; 26 | text = `${term}`; 27 | } 28 | 29 | const aws = new AwsClient({ 30 | accessKeyId, 31 | secretAccessKey, 32 | region, 33 | }); 34 | 35 | const requestBody = JSON.stringify({ 36 | Engine: 'neural', 37 | LanguageCode: 'ja-JP', 38 | OutputFormat: 'mp3', 39 | Text: text, 40 | TextType: text_type, 41 | VoiceId: 'Tomoko', 42 | }); 43 | 44 | const response = await aws.fetch(`https://polly.${region}.amazonaws.com/v1/speech`, { 45 | method: 'POST', 46 | headers: { 'Content-Type': 'application/json' }, 47 | body: requestBody, 48 | }); 49 | 50 | log('info', 'generated_new_tts', `Generated new TTS audio for term: ${term}, reading: ${reading}`, { 51 | term: term, 52 | reading: reading, 53 | pitch: pitch, 54 | text_type: text_type, 55 | text: text, 56 | }); 57 | 58 | if (!response.ok) { 59 | const response_text = await response.text(); 60 | log('error', 'polly_failed', `AWS Polly request failed with status ${response.status} for term: ${term}, reading: ${reading}`, { 61 | status: response.status, 62 | response_text: response_text, 63 | term: term, 64 | reading: reading, 65 | }); 66 | throw new StatusError(response.status, 'AWS Polly request failed.'); 67 | } 68 | 69 | return await response.blob(); 70 | } 71 | -------------------------------------------------------------------------------- /scripts/upload-to-r2.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Check for required environment variables 4 | if [[ -z "$R2_ACCESS_KEY_ID" || -z "$R2_SECRET_ACCESS_KEY" || -z "$R2_ACCOUNT_ID" ]]; then 5 | echo "Error: Required environment variables not set" 6 | echo "Please set the following environment variables:" 7 | echo " - R2_ACCESS_KEY_ID: Your R2 access key" 8 | echo " - R2_SECRET_ACCESS_KEY: Your R2 secret key" 9 | echo " - R2_ACCOUNT_ID: Your Cloudflare account ID" 10 | exit 1 11 | fi 12 | 13 | # Array of folders to upload 14 | folders=( 15 | "daijisen_files" 16 | "forvo_ext2_files" 17 | "forvo_ext_files" 18 | "forvo_files" 19 | "jpod_files" 20 | "nhk16_files" 21 | "ozk5_files" 22 | "shinmeikai8_files" 23 | "taas_files" 24 | "tts_files" 25 | ) 26 | 27 | # Bucket name 28 | BUCKET="yomitan-audio-bucket" 29 | 30 | # Create a temporary rclone config file 31 | TEMP_CONFIG=$(mktemp) 32 | cat > "$TEMP_CONFIG" << EOF 33 | [temp_r2] 34 | type = s3 35 | provider = Cloudflare 36 | access_key_id = $R2_ACCESS_KEY_ID 37 | secret_access_key = $R2_SECRET_ACCESS_KEY 38 | endpoint = https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com 39 | acl = private 40 | EOF 41 | 42 | for folder in "${folders[@]}"; do 43 | echo "Processing folder: $folder" 44 | 45 | # List remote files (relative paths) and save to a temporary file. 46 | rclone lsf "temp_r2:$BUCKET/$folder" --recursive --files-only --config="$TEMP_CONFIG" > "${folder}_remote.txt" 47 | 48 | # List local files (relative to the folder) using find. 49 | find "data/$folder" -type f -printf '%P\n' > "${folder}_local.txt" 50 | 51 | # Sort both files and use 'comm' to find files present locally but missing remotely. 52 | comm -23 <(sort "${folder}_local.txt") <(sort "${folder}_remote.txt") > "${folder}_missing.txt" 53 | 54 | missing_count=$(wc -l < "${folder}_missing.txt") 55 | echo "$missing_count files missing on remote for $folder." 56 | 57 | if [ "$missing_count" -gt 0 ]; then 58 | echo "Uploading missing files for $folder..." 59 | rclone copy "data/$folder" "temp_r2:$BUCKET/$folder" \ 60 | --files-from "${folder}_missing.txt" \ 61 | --progress \ 62 | --transfers=32 \ 63 | --checkers=32 \ 64 | --fast-list \ 65 | --config="$TEMP_CONFIG" 66 | else 67 | echo "No missing files in $folder." 68 | fi 69 | 70 | # Remove temporary files 71 | rm "${folder}_remote.txt" "${folder}_local.txt" "${folder}_missing.txt" 72 | done 73 | 74 | # Remove temporary config file 75 | rm "$TEMP_CONFIG" 76 | 77 | echo "All folders processed." -------------------------------------------------------------------------------- /src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import { error, IRequest, StatusError } from 'itty-router'; 2 | 3 | export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; 4 | 5 | export interface LogOptions { 6 | truncateArrays?: number; // Max number of items to include from arrays 7 | maxDepth?: number; // Max object nesting to serialize 8 | } 9 | 10 | export function log(level: LogLevel, event: string, message: string, data?: any, options: LogOptions = {}) { 11 | const { truncateArrays = 10, maxDepth = 2 } = options; 12 | 13 | const preparedData = data ? prepareForLogging(data, truncateArrays, maxDepth, 0) : undefined; 14 | 15 | const logObject = { 16 | message, 17 | level, 18 | event, 19 | ...(preparedData ? { data: preparedData } : {}), 20 | timestamp: new Date().toISOString(), 21 | }; 22 | 23 | console[level](JSON.stringify(logObject)); 24 | } 25 | 26 | function prepareForLogging(obj: any, maxArrayItems: number, maxDepth: number, currentDepth: number): any { 27 | if (obj === null) return null; 28 | if (obj === undefined) return undefined; 29 | 30 | // Handle non-serializable or special types 31 | if (typeof obj === 'function') return '[Function]'; 32 | if (typeof obj === 'symbol') return '[Symbol]'; 33 | if (obj instanceof Error) { 34 | return { 35 | name: obj.name, 36 | message: obj.message, 37 | stack: obj.stack 38 | }; 39 | } 40 | 41 | if (currentDepth > maxDepth) return '[Object]'; 42 | 43 | if (Array.isArray(obj)) { 44 | if (obj.length > maxArrayItems) { 45 | return [...obj.slice(0, maxArrayItems), `[...${obj.length - maxArrayItems} more items]`]; 46 | } 47 | return obj.map((item) => prepareForLogging(item, maxArrayItems, maxDepth, currentDepth + 1)); 48 | } 49 | 50 | if (obj && typeof obj === 'object') { 51 | try { 52 | return Object.fromEntries( 53 | Object.entries(obj).map(([key, val]) => [key, prepareForLogging(val, maxArrayItems, maxDepth, currentDepth + 1)]) 54 | ); 55 | } catch (e) { 56 | return '[Unserializable Object]'; 57 | } 58 | } 59 | 60 | return obj; 61 | } 62 | 63 | export const errorHandler = (err: Error, request: IRequest) => { 64 | if (err instanceof StatusError) { 65 | return error(err.status, { message: err.message }); 66 | } 67 | 68 | log('error', 'unhandled_error', `Unhandled error: ${err.message}`, { 69 | url: request.url, 70 | method: request.method, 71 | error: err.message, 72 | stack: err.stack, 73 | }); 74 | 75 | return error(500, { message: 'Internal Server Error' }); 76 | }; 77 | -------------------------------------------------------------------------------- /src/routes/audio.ts: -------------------------------------------------------------------------------- 1 | import { AutoRouter, IRequest, json, error, createResponse } from 'itty-router'; 2 | import { unpack_term_reading, unpack_pitch, unpack_sources } from '../lib/queryUtils'; 3 | import { queryAudioDB, generateDisplayNames, sortResults } from '../lib/queryAudioDB'; 4 | import { generateYomitanResponseObject } from '../lib/yomitanResponse'; 5 | import { log } from '../lib/logger'; 6 | import { createTTSEntries } from '../lib/ttsUtils'; 7 | import { verifyApiKey } from '../lib/keyVerification'; 8 | import { fetchAudioTTS, fetchAudioDB as returnAudio, saveAudioTTS } from '../lib/fetchAudioDB'; 9 | import { generateTTSAudio } from '../lib/awsPolly'; 10 | import { withApiKey } from '../lib/middleware'; 11 | 12 | export const router = AutoRouter({ base: '/audio', catch: undefined }); 13 | 14 | router.get('/list', withApiKey, async (request: IRequest, env: Env) => { 15 | await verifyApiKey(request, env); 16 | 17 | log('info', 'audio_list', `Searching for audio with: ${request.url}`, request.query); 18 | 19 | const [term, reading] = await unpack_term_reading(request); 20 | 21 | const sources = await unpack_sources(request); 22 | 23 | const results = await queryAudioDB(term, reading, sources, env); 24 | 25 | log('info', 'db_result_count', `Searched for ${term} + ${reading} in ${sources} and got ${results.length} results`, { 26 | resultCount: results.length, 27 | term: term, 28 | reading: reading, 29 | sources: sources, 30 | }); 31 | 32 | const displayNames = await generateDisplayNames(results, term, reading); 33 | 34 | const sortedResults = await sortResults(results, displayNames); 35 | 36 | const ttsEntries = await createTTSEntries(term, reading, sources, env, request); 37 | 38 | const yomitanResponses = await generateYomitanResponseObject(sortedResults, ttsEntries, request, env); 39 | 40 | return json(yomitanResponses, { status: 200 }); 41 | }); 42 | 43 | export const mp3 = createResponse('audio/mpeg'); 44 | 45 | router.get('/get/:source/:file', withApiKey, async (request: IRequest, env: Env) => { 46 | await verifyApiKey(request, env); 47 | 48 | const source = request.params.source; 49 | const file = decodeURIComponent(request.params.file); 50 | 51 | log('info', 'audio_get', `Fetching audio: ${source}/${file}`, { source, file }); 52 | 53 | const audio = await returnAudio(source, file, env); 54 | return mp3(audio, { status: 200 }); 55 | }); 56 | 57 | router.get('/get/:source/:folder/:file', withApiKey, async (request: IRequest, env: Env) => { 58 | await verifyApiKey(request, env); 59 | 60 | const source = request.params.source; 61 | const file = decodeURIComponent(request.params.folder) + '/' + decodeURIComponent(request.params.file); 62 | 63 | log('info', 'audio_get', `Fetching audio: ${source}/${file}`, { source, file }); 64 | 65 | const audio = await returnAudio(source, file, env); 66 | return mp3(audio, { status: 200 }); 67 | }); 68 | 69 | router.get('/tts', withApiKey, async (request: IRequest, env: Env, context: ExecutionContext) => { 70 | await verifyApiKey(request, env); 71 | 72 | const [term, reading] = await unpack_term_reading(request); 73 | const pitch = await unpack_pitch(request); 74 | 75 | const tts_identifier = encodeURIComponent(term + reading + pitch); 76 | 77 | const audio = await fetchAudioTTS(tts_identifier, env); 78 | 79 | if (audio !== null) { 80 | log('info', 'using_cached_tts', `Using cached TTS data: ${term}, ${reading}, ${pitch} `, { 81 | term: term, 82 | reading: reading, 83 | pitch: pitch, 84 | }); 85 | return mp3(audio, { status: 200 }); 86 | } 87 | 88 | const generatedAudio = await generateTTSAudio(term, reading, pitch, env); 89 | 90 | context.waitUntil(saveAudioTTS(tts_identifier, generatedAudio, env)); 91 | 92 | return mp3(generatedAudio, { status: 200 }); 93 | }); 94 | 95 | router.all('*', () => { 96 | return error(400); 97 | }); 98 | -------------------------------------------------------------------------------- /src/lib/queryUtils.ts: -------------------------------------------------------------------------------- 1 | import { IRequest, StatusError } from 'itty-router'; 2 | import { log } from './logger'; 3 | import { katakanaToHiragana } from './utils'; 4 | 5 | export function stripHtmlTags(input: string): string { 6 | input = input.replace(/<[^>]*>+/g, ''); 7 | return input; 8 | } 9 | 10 | export function extractQueryParam(param: unknown): string | undefined { 11 | if (typeof param === 'undefined') { 12 | return undefined; 13 | } 14 | 15 | if (Array.isArray(param)) { 16 | return param.length > 0 ? String(param[0]) : ''; 17 | } 18 | 19 | return String(param); 20 | } 21 | 22 | function extractQueryParamArray(param: unknown): string[] | undefined { 23 | if (typeof param === 'undefined') { 24 | return undefined; 25 | } 26 | 27 | if (Array.isArray(param)) { 28 | return param; 29 | } 30 | 31 | return String(param).split(','); 32 | } 33 | 34 | export async function unpack_term_reading(request: IRequest): Promise<[string, string]> { 35 | const query = request.query || {}; 36 | 37 | const rawTerm = extractQueryParam(query.term); 38 | const rawReading = extractQueryParam(query.reading); 39 | 40 | if (rawTerm === undefined && rawReading === undefined) { 41 | throw new StatusError(400, 'Missing required parameters: term or reading'); 42 | } 43 | 44 | const termWithoutHtml = rawTerm ? stripHtmlTags(rawTerm) : rawReading ? stripHtmlTags(rawReading) : ''; 45 | const term = termWithoutHtml.trim(); 46 | 47 | const readingWithoutHtml = rawReading && rawReading !== 'null' && rawReading !== 'undefined' ? stripHtmlTags(rawReading) : ''; 48 | const reading = readingWithoutHtml.trim(); 49 | 50 | if (term === '') { 51 | throw new StatusError(400, 'Empty parameters: term cannot be empty'); 52 | } 53 | 54 | if (term.length > 120) { 55 | throw new StatusError(400, 'Term parameter is too long (max 120 characters)'); 56 | } 57 | 58 | if (reading.length > 120) { 59 | throw new StatusError(400, 'Reading parameter is too long (max 120 characters)'); 60 | } 61 | 62 | const hiraganaReading = katakanaToHiragana(reading); 63 | 64 | log('info', 'unpack_term_reading', `Unpacked term: "${term}" and reading: "${hiraganaReading}"`, { 65 | rawTerm: rawTerm, 66 | rawReading: rawReading, 67 | term: term, 68 | reading: hiraganaReading, 69 | }); 70 | 71 | return [term, hiraganaReading]; 72 | } 73 | 74 | const VALID_AUDIO_SOURCES = [ 75 | 'all', 76 | 'nhk16', 77 | 'daijisen', 78 | 'shinmeikai8', 79 | 'jpod', 80 | 'taas', 81 | 'ozk5', 82 | 'forvo', 83 | 'forvo_ext', 84 | 'forvo_ext2', 85 | 'tts', 86 | ] as const; 87 | 88 | export type AudioSource = (typeof VALID_AUDIO_SOURCES)[number]; 89 | 90 | function isAudioSource(source: string): source is AudioSource { 91 | return VALID_AUDIO_SOURCES.includes(source as any); 92 | } 93 | 94 | export async function unpack_sources(request: IRequest): Promise { 95 | const query = request.query || {}; 96 | 97 | const rawSources = extractQueryParamArray(query.sources); 98 | 99 | if (rawSources === undefined) { 100 | return ['all']; 101 | } 102 | 103 | // Filter out invalid sources 104 | const validSources = rawSources.filter(isAudioSource); 105 | 106 | if (validSources.length === 0) { 107 | return ['all']; 108 | } 109 | 110 | log('info', 'unpack_sources', `Unpacked audio sources: ${validSources.join(', ')}`, { 111 | event: 'unpack_sources', 112 | rawSources: rawSources, 113 | validSources: validSources, 114 | }); 115 | 116 | return validSources; 117 | } 118 | 119 | export async function unpack_pitch(request: IRequest): Promise { 120 | const query = request.query || {}; 121 | 122 | const rawPitch = extractQueryParam(query.pitch); 123 | 124 | if (rawPitch === undefined || rawPitch.length === 0) { 125 | return ''; 126 | } else { 127 | return rawPitch; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/lib/queryAudioDB.ts: -------------------------------------------------------------------------------- 1 | import { StatusError } from 'itty-router'; 2 | import { katakanaToHiragana } from './utils'; 3 | 4 | import type { AudioSource } from './queryUtils'; 5 | import { log } from './logger'; 6 | 7 | export interface AudioEntry { 8 | expression: string; 9 | reading: string; 10 | source: string; 11 | file: string; 12 | display: string; 13 | } 14 | 15 | export async function queryAudioDB(term: string, reading: string, sources: AudioSource[], env: Env): Promise { 16 | let baseCondition = 'WHERE expression = ?'; 17 | const params: any[] = [term]; 18 | 19 | if (reading && reading.trim() !== '') { 20 | baseCondition = `WHERE (expression = ? OR reading = ?)`; 21 | const convertedReading = katakanaToHiragana(reading); 22 | params.push(convertedReading); 23 | } 24 | 25 | if (sources.length > 0 && !sources.includes('all')) { 26 | const placeholders = sources.map(() => '?').join(', '); 27 | baseCondition += ` AND source IN (${placeholders})`; 28 | params.push(...sources); 29 | } 30 | 31 | const query = `SELECT expression, reading, source, file, display FROM entries ${baseCondition}`; 32 | 33 | let d1results: D1Result = await env.yomitan_audio_d1_db 34 | .prepare(query) 35 | .bind(...params) 36 | .all(); 37 | 38 | if (d1results.success) { 39 | return d1results.results as AudioEntry[]; 40 | } else { 41 | log('error', 'query_pitch_db_failed', `Database query failed for term: ${term}, reading: ${reading}`, { term: term, reading: reading, d1_result: d1results.error || 'Unknown Error' }); 42 | throw new StatusError(500, 'Database query failed'); 43 | } 44 | } 45 | 46 | export async function generateDisplayNames(entries: AudioEntry[], term: string, reading: string): Promise { 47 | let names: string[] = []; 48 | entries.forEach((entry) => { 49 | let name = `${entry.source}`; 50 | if (entry.display) { 51 | name += `: ${entry.display}`; 52 | } 53 | 54 | if (term == entry.expression && reading == entry.reading) { 55 | name += ` (Expression+Reading)`; 56 | } else if (term == entry.expression) { 57 | name += ` (Only Expression)`; 58 | } else if (reading == entry.reading) { 59 | name += ` (Only Reading)`; 60 | } 61 | 62 | names.push(name); 63 | }); 64 | 65 | return names; 66 | } 67 | 68 | export async function sortResults(entries: AudioEntry[], names: string[]): Promise { 69 | const sourcePriority: { [key: string]: number } = { 70 | nhk16: 0, 71 | daijisen: 1, 72 | shinmeikai8: 2, 73 | jpod: 3, 74 | taas: 4, 75 | ozk5: 5, 76 | forvo: 6, 77 | forvo_ext: 7, 78 | forvo_ext2: 8, 79 | tts: 9, 80 | }; 81 | 82 | const getMatchTypePriority = (name?: string): number => { 83 | if (!name) return 3; 84 | if (name.includes('(Expression+Reading)')) return 0; 85 | if (name.includes('(Only Expression)')) return 1; 86 | if (name.includes('(Only Reading)')) return 2; 87 | return 3; 88 | }; 89 | 90 | const result = entries.map((entry, index) => { 91 | const copy = { ...entry }; 92 | 93 | if (index < names.length) { 94 | copy.display = names[index]; 95 | } else { 96 | copy.display = undefined as unknown as string; 97 | } 98 | 99 | return copy; 100 | }); 101 | 102 | result.sort((a, b) => { 103 | const aMatchPriority = getMatchTypePriority(a.display); 104 | const bMatchPriority = getMatchTypePriority(b.display); 105 | 106 | if (aMatchPriority !== bMatchPriority) { 107 | return aMatchPriority - bMatchPriority; 108 | } 109 | 110 | const aPriority = sourcePriority[a.source] ?? Number.MAX_SAFE_INTEGER; 111 | const bPriority = sourcePriority[b.source] ?? Number.MAX_SAFE_INTEGER; 112 | return aPriority - bPriority; 113 | }); 114 | 115 | return result; 116 | } 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Cloudflare Worker](img/worker.png) 2 | 3 | # Cloudflare Worker Yomitan Audio Source 4 | 5 | **!! Japanese Only !!** 6 | 7 | **No commits does not mean not maintained. If there's a problem, create an issue!** 8 | 9 | This is a self deployable Cloudflare Worker that serves as an audio source for Yomitan. Hosting it for yourself falls completely within the free limits of Cloudflare Workers. As an audio source it can be used from any device without having to run a local server or install an Anki-Addon or have any local storage used up. [^1] 10 | 11 | Additionally it has extra features like pitch accent aware TTS and boasts the largest collection of Japanese audio available. Read more here: 12 | 13 | ## Too lazy to setup? 14 | 15 | If you want a no-hassle setup consider signing up for the $1 tier on the Patreon: . It saves you the trouble of setting up a Cloudflare and AWS account to enjoy all features. 🙂 16 | 17 | ## Setup 18 | 19 | ### Requirements 20 | 21 | 1. Ensure `npm` and `rclone` are installed on your system. 22 | 23 | ### Cloudflare 24 | 25 | 1. Create a Cloudflare account → https://dash.cloudflare.com/sign-up 26 | 27 | 2. Create a worker and select the "Hello World" template. 28 | 29 | ![Worker Setup](img/add_worker.png) 30 | 31 | ![Hello World](img/helloworld.png) 32 | 33 | 3. Name your worker `yomitan-audio-worker` and deploy it. 34 | 35 | ![Worker Name](img/worker_name.png) 36 | 37 | 4. Go to `R2 Project Storage` in the left sidebar and create a new bucket named `yomitan-audio-bucket`. 38 | 39 | 5. Go to `Storage & Database` → `D1 SQL Database` and create a new database called `yomitan-audio-db`. 40 | 41 | ### Locally 42 | 43 | 1. Clone the repository and run the following commands: 44 | 45 | - `npm install` to install the dependencies. 46 | - `npx wrangler login` to authenticate with your Cloudflare account. 47 | 48 | 2. Copy `wrangler.toml.example` to `wrangler.toml` and replace the `database_id` entry with the ID of your previously created R2 bucket. Also adjust: 49 | 50 | - `AUTHENTICATION_ENABLED` if you want the endpoints to be protected by a password **(STRONGLY RECOMMENDED)**. 51 | - `AWS_POLLY_ENABLED` if you want the pitch acccent TTS, as well as the AWS credentials with Polly access. 52 | 53 | If you change these also run `npx wrangler types` to update the types. 54 | 55 | 3. Copy `.dev.vars.example` to `.dev.vars` and replace the following: 56 | 57 | - `API_KEYS` with a comma separated list of API keys that will be used to authenticate requests. 58 | - AWS credentials with Polly access. 59 | 60 | 4. Download the audio data and put it into the repository folder. [MORE INFO ON DISCORD](https://animecards.site/discord/). (You should have a bunch of folders ending with `_files` in `data`.) 61 | 62 | 5. Import the entries and pitch data into your D1 database by running the following command: 63 | 64 | - `npx wrangler d1 execute yomitan-audio-db --remote --file=data/entry_and_pitch_db.sql` 65 | 66 | 6. Go [here](https://dash.cloudflare.com/?to=/:account/r2/api-tokens) and create an API Token to access your R2 bucket. 67 | 68 | ![API Token](img/api_key.png) 69 | 70 | 7. Upload the audio files to your R2 bucket by running the following command, replacing the placeholders with your own values from the previous step: 71 | 72 | - `R2_ACCESS_KEY_ID="your_access_key" R2_SECRET_ACCESS_KEY="your_secret_key" R2_ACCOUNT_ID="your_cloudflare_account_id" bash scripts/upload-to-r2.sh` 73 | 74 | 8. Upload the variables in `.dev.vars` to Cloudflare by running the following command: 75 | 76 | - `npx wrangler secret put API_KEYS` 77 | - `npx wrangler secret put AWS_ACCESS_KEY_ID` 78 | - `npx wrangler secret put AWS_SECRET_ACCESS_KEY` 79 | 80 | 9. Deploy the worker by running the following command: 81 | 82 | - `npx wrangler deploy` 83 | 84 | ### Yomitan 85 | 86 | 1. Go to the Yomitan settings and set the `Audio Source URL` to your worker URL and `audio/list?term={term}&reading={reading}&apiKey=yourApiKey` so for example `https://yomitan-audio-worker.friedrichde.workers.dev/audio/list?term={term}&reading={reading}&apiKey=m9NixGU7qtWv4SYr` 87 | 88 | ![Yomitan Settings](img/yomitan_settings.png) 89 | 90 | Your worker should be up and running now and you getting unlimited Yomitan audio through your worker! 91 | 92 | [^1]: Note that you will still need to add a payment method to your Cloudflare account to create some rescoures, but you will not be charged for using this worker for yourself. 93 | -------------------------------------------------------------------------------- /src/lib/ttsUtils.ts: -------------------------------------------------------------------------------- 1 | import { IRequest, StatusError } from 'itty-router'; 2 | import { YomitanAudioSource } from './yomitanResponse'; 3 | import { AudioSource } from './queryUtils'; 4 | import { log } from './logger'; 5 | import { generatePronunciationVariants } from './utils'; 6 | 7 | export interface PitchDBEntry { 8 | id: string; 9 | expression: string; 10 | reading: string; 11 | pitch: string; 12 | count: string; 13 | } 14 | 15 | export async function queryPitchDB(term: string, reading: string, env: Env): Promise { 16 | let baseCondition = 'WHERE expression = ?'; 17 | const params: any[] = [term]; 18 | 19 | if (reading && reading.trim() !== '') { 20 | baseCondition = `WHERE (expression = ? AND reading = ?)`; 21 | params.push(reading); 22 | } 23 | 24 | const query = `SELECT id, expression, reading, pitch, count FROM pitch_accents ${baseCondition}`; 25 | 26 | let d1results: D1Result = await env.yomitan_audio_d1_db 27 | .prepare(query) 28 | .bind(...params) 29 | .all(); 30 | 31 | if (d1results.success) { 32 | let pitch_entries = d1results.results as PitchDBEntry[]; 33 | pitch_entries.sort((a, b) => parseInt(b.count) - parseInt(a.count)); 34 | 35 | return pitch_entries; 36 | } else { 37 | log('error', 'query_pitch_db_failed', `Failed to query pitch database for term: ${term}${reading ? ', reading: ' + reading : ''}`, { 38 | term: term, 39 | reading: reading, 40 | d1_result: d1results.error || 'Unknown Error', 41 | }); 42 | throw new StatusError(500, 'Database query failed.'); 43 | } 44 | } 45 | 46 | export function createPitchEntryNoDB(term: string, reading: string): PitchDBEntry { 47 | const pitchEntry: PitchDBEntry = { 48 | id: 'Default - No DB', 49 | expression: term, 50 | reading: reading, 51 | pitch: '', 52 | count: '0', 53 | }; 54 | return pitchEntry; 55 | } 56 | 57 | export async function createAllPossiblePronunciations(term: string, reading: string): Promise { 58 | const possiblePitches = await generatePronunciationVariants(reading); 59 | return possiblePitches.map((pitch) => { 60 | return { 61 | id: 'Forced', 62 | expression: term, 63 | reading: reading, 64 | pitch: pitch, 65 | count: '0', 66 | }; 67 | }); 68 | } 69 | export async function createTTSEntries( 70 | term: string, 71 | reading: string, 72 | sources: AudioSource[], 73 | env: Env, 74 | request: IRequest 75 | ): Promise { 76 | if (!env.AWS_POLLY_ENABLED) { 77 | return []; 78 | } 79 | 80 | if (sources.includes('all') || sources.includes('tts')) { 81 | const pitchDBEntries = await queryPitchDB(term, reading, env); 82 | const existingPitches = new Set(pitchDBEntries.map((entry) => entry.pitch)); 83 | 84 | if (pitchDBEntries.length === 0) { 85 | pitchDBEntries.push(createPitchEntryNoDB(term, reading)); 86 | } 87 | 88 | const allTtsPronunciations = await createAllPossiblePronunciations(term, reading); 89 | const uniqueTtsPronunciations = allTtsPronunciations.filter((entry) => !existingPitches.has(entry.pitch)); 90 | const finalTtsCollection = [...pitchDBEntries, ...uniqueTtsPronunciations]; 91 | 92 | const ttsEntries: YomitanAudioSource[] = finalTtsCollection.map((entry) => { 93 | const url = new URL(request.url); 94 | const audioUrl = new URL('/audio/tts', url.origin); 95 | 96 | audioUrl.searchParams.set('term', entry.expression); 97 | audioUrl.searchParams.set('reading', entry.reading); 98 | audioUrl.searchParams.set('pitch', entry.pitch); 99 | 100 | if (env.AUTHENTICATION_ENABLED) { 101 | audioUrl.searchParams.set('apiKey', request.apiKey); 102 | } 103 | 104 | let name = 'TTS'; 105 | if (!isNaN(parseInt(entry.id))) { 106 | name += ` (${entry.pitch} Pitch DB)`; 107 | } else if (entry.pitch) { 108 | name += ` (${entry.pitch} ${entry.id})`; 109 | } else { 110 | name += ` (${entry.id})`; 111 | } 112 | 113 | return { 114 | name: name, 115 | url: audioUrl.toString(), 116 | }; 117 | }); 118 | return ttsEntries; 119 | } else { 120 | return []; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Character mappings for converting between katakana and hiragana 3 | */ 4 | const KATAKANA_CHART = 5 | 'ァアィイゥウェエォオカガカ゚キギキ゚クグク゚ケゲケ゚コゴコ゚サザシジスズセゼソゾタダチヂッツヅテデトドナニヌネノハバパヒビピフブプヘベペホボポマミムメモャヤュユョヨラリルレロヮワヰヱヲンヴヵヶヽヾ'; 6 | const HIRAGANA_CHART = 7 | 'ぁあぃいぅうぇえぉおかがか゚きぎき゚くぐく゚けげけ゚こごこ゚さざしじすずせぜそぞただちぢっつづてでとどなにぬねのはばぱひびぴふぶぷへべぺほぼぽまみむめもゃやゅゆょよらりるれろゎわゐゑをんゔゕゖゝゞ'; 8 | 9 | export function katakanaToHiragana(text: string): string { 10 | if (!text) return ''; 11 | 12 | let result = ''; 13 | for (let i = 0; i < text.length; i++) { 14 | const char = text[i]; 15 | const index = KATAKANA_CHART.indexOf(char); 16 | if (index >= 0) { 17 | result += HIRAGANA_CHART[index]; 18 | } else { 19 | result += char; 20 | } 21 | } 22 | 23 | return result; 24 | } 25 | 26 | export function hiraganaToKatakana(text: string): string { 27 | if (!text) return ''; 28 | 29 | let result = ''; 30 | for (let i = 0; i < text.length; i++) { 31 | const char = text[i]; 32 | const index = HIRAGANA_CHART.indexOf(char); 33 | if (index >= 0) { 34 | result += KATAKANA_CHART[index]; 35 | } else { 36 | result += char; 37 | } 38 | } 39 | 40 | return result; 41 | } 42 | 43 | async function generateVariantsFromChars(chars: string[], index: number): Promise { 44 | if (index >= chars.length) { 45 | return ['']; 46 | } 47 | 48 | const results: string[] = []; 49 | const current = chars[index]; 50 | 51 | // Check for special patterns 52 | if (index < chars.length - 1) { 53 | // Handle おお→おー pattern (and other double vowels) 54 | if (current === 'オ' && chars[index + 1] === 'オ') { 55 | // Always replace おお with おー 56 | const remainingVariants = await generateVariantsFromChars(chars, index + 2); 57 | for (const suffix of remainingVariants) { 58 | results.push('オー' + suffix); 59 | } 60 | return results; 61 | } 62 | 63 | // Handle おう→おー/おう pattern (generate both variants) 64 | if ( 65 | chars[index + 1] === 'ウ' && 66 | (current === 'オ' || 67 | current === 'コ' || 68 | current === 'ソ' || 69 | current === 'ト' || 70 | current === 'ノ' || 71 | current === 'ホ' || 72 | current === 'モ' || 73 | current === 'ロ' || 74 | current === 'ゴ' || 75 | current === 'ゾ' || 76 | current === 'ド' || 77 | current === 'ボ' || 78 | current === 'ポ' || 79 | current === 'ヨ' || 80 | current === 'ショ' || 81 | current === 'チョ' || 82 | current === 'ジョ') 83 | ) { 84 | // Generate both variants: おう and おー 85 | const remainingVariants = await generateVariantsFromChars(chars, index + 2); 86 | for (const suffix of remainingVariants) { 87 | results.push(current + 'ウ' + suffix); // Keep as おう 88 | results.push(current + 'ー' + suffix); // Convert to おー 89 | } 90 | return results; 91 | } 92 | 93 | // Handle えい→えー pattern 94 | if ( 95 | chars[index + 1] === 'イ' && 96 | (current === 'エ' || 97 | current === 'ケ' || 98 | current === 'セ' || 99 | current === 'テ' || 100 | current === 'ネ' || 101 | current === 'ヘ' || 102 | current === 'メ' || 103 | current === 'レ' || 104 | current === 'ゲ' || 105 | current === 'ゼ' || 106 | current === 'デ' || 107 | current === 'ベ' || 108 | current === 'ペ') 109 | ) { 110 | // Always replace えい with えー 111 | const remainingVariants = await generateVariantsFromChars(chars, index + 2); 112 | for (const suffix of remainingVariants) { 113 | results.push(current + 'ー' + suffix); 114 | } 115 | return results; 116 | } 117 | } 118 | 119 | // Check if the current character is a candidate vowel extension. 120 | if (current === 'ウ' && index > 0) { 121 | // The base two options: keep as is, or replace with long-vowel mark. 122 | const options = ['ウ', 'ー']; 123 | 124 | // For each variant option at this position, combine with the variants from the remainder of the string. 125 | for (const option of options) { 126 | for (const suffix of await generateVariantsFromChars(chars, index + 1)) { 127 | results.push(option + suffix); 128 | } 129 | } 130 | } else { 131 | // For non-candidate characters, just append the current character. 132 | for (const suffix of await generateVariantsFromChars(chars, index + 1)) { 133 | results.push(current + suffix); 134 | } 135 | } 136 | 137 | return results; 138 | } 139 | export function isKana(text: string): boolean { 140 | if (!text) return false; 141 | // Regular expression for hiragana and katakana ranges 142 | const kanaRegex = /^[\u3040-\u309F\u30A0-\u30FF]+$/; 143 | return kanaRegex.test(text); 144 | } 145 | 146 | export async function generatePronunciationVariants(input: string): Promise { 147 | // Return empty array for inputs longer than 12 characters to prevent performance issues 148 | if (!isKana(input)) { 149 | return []; 150 | } 151 | 152 | if (input.length > 12) { 153 | return []; 154 | } 155 | 156 | const katakana = hiraganaToKatakana(input); 157 | const chars = [...katakana]; 158 | 159 | // Generate base variants (handling ウ/ー replacements) 160 | const baseVariants = await generateVariantsFromChars(chars, 0); 161 | const finalVariants: string[] = []; 162 | 163 | // For each base variant, generate pitch accent positions 164 | for (const variant of baseVariants) { 165 | // Add the base variant (no pitch drop) 166 | finalVariants.push(variant); 167 | 168 | // Add variants with pitch drops at valid positions 169 | const variantChars = [...variant]; 170 | for (let i = 0; i < variantChars.length - 1; i++) { 171 | const pitchVariant = variantChars.slice(0, i + 1).join('') + "'" + variantChars.slice(i + 1).join(''); 172 | finalVariants.push(pitchVariant); 173 | } 174 | } 175 | 176 | return finalVariants; 177 | } 178 | 179 | export const redirect = (url: string, status = 302) => { 180 | return new Response(null, { 181 | status, 182 | headers: { Location: url }, 183 | }); 184 | }; 185 | --------------------------------------------------------------------------------