diff --git a/.secrets.env.example b/.secrets.env.example index 474d447..7bd13a7 100644 --- a/.secrets.env.example +++ b/.secrets.env.example @@ -7,7 +7,6 @@ # ./scripts/put-secrets.sh --env production # prod(.secrets.prod.env を使うなら SECRETS_FILE=... を併用) SESSION_JWT_SECRET= -AZURE_SPEECH_KEY= # GOOGLE_PLAY_SA_KEY はここに 1 行 JSON を入れてもよいが、複数行 JSON は # GOOGLE_PLAY_SA_KEY_FILE=./sa.json ./scripts/put-secrets.sh の方が扱いやすい。 GOOGLE_PLAY_SA_KEY= diff --git a/README.md b/README.md index 7e7ff5b..7805d2a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ single Worker. ## Features -- **TTS synthesis** (`POST /tts`): synthesizes SSML into audio via Azure Speech and caches it in KV/R2. +- **TTS synthesis** (`POST /tts`): synthesizes plain text into audio via OpenAI `gpt-4o-mini-tts` and caches it in KV/R2. - **Session issuance** (`POST /auth/token`): issues a short-lived session JWT from an install ID (the replacement for Firebase anonymous auth). - **Feedback intake** (`POST /postFeedback`): enqueues feedback onto the triage queue. - **Image upload** (`POST /feedback/upload-image`): stores feedback images in R2 and returns a public URL. @@ -22,7 +22,7 @@ single Worker. - **R2** — audio binaries and feedback images - **Cloudflare Queues** — `feedback-triage` - **Workers AI** — feedback triage -- **Azure Speech** — TTS synthesis (SSML) +- **OpenAI** — TTS synthesis (`gpt-4o-mini-tts`) and the conversational agent - **Google Android Publisher API** — Google Play review retrieval (service-account JWT) - **TypeScript / Biome / Jest / Wrangler** @@ -60,8 +60,8 @@ wrangler queues create feedback-triage-dev ```bash wrangler secret put SESSION_JWT_SECRET # signing key for session JWTs (any long random string) -wrangler secret put AZURE_SPEECH_KEY # Azure Speech subscription key wrangler secret put GOOGLE_PLAY_SA_KEY # Android Publisher SA key JSON (single-line string) +wrangler secret put OPENAI_API_KEY # TTS synthesis and the conversational agent wrangler secret put OCTOKIT_PAT wrangler secret put DISCORD_CS_WEBHOOK_URL wrangler secret put DISCORD_CRASH_WEBHOOK_URL @@ -76,7 +76,7 @@ You can also bulk-load secrets with the helper scripts: copy ### Non-secret configuration (vars) -See `vars` in `wrangler.jsonc`. Configure the Azure region, voice names, AI model +See `vars` in `wrangler.jsonc`. Configure the TTS model, voice names, AI model name, package name, public upload URL (the R2 public domain), and so on per environment. @@ -103,10 +103,51 @@ format. A session JWT is obtained from `POST /auth/token` (body `{ "installId": "" }`). +### `POST /tts` + +Synthesis runs on OpenAI `gpt-4o-mini-tts`, which does **not** interpret SSML — +the client sends plain text and steers delivery with `instructions`. + +```json +{ + "data": { + "textJa": "次は、オオサキです", + "textEn": "The next station is Osaki, J-Y 24.", + "model": "gpt-4o-mini-tts", + "jaVoiceName": "nova", + "enVoiceName": "nova", + "instructionsJa": "…", + "instructionsEn": "…" + } +} +``` + +Every field is optional except that **at least one of `textJa` / `textEn` must +be present**. Synthesis is billed per character, so the app omits a language the +user has switched off; only the languages it asks for are synthesized, cached, +and returned. `model` and the voice names are validated against an allowlist — +anything unrecognized falls back to the KV config (`config:tts`) and then to the +`TTS_*` vars, so a client cannot name an arbitrary (expensive) model. + +The response carries only the requested languages: + +```json +{ + "result": { + "id": "", + "jaAudioContent": "", + "jaAudioMimeType": "audio/mpeg", + "enAudioContent": "", + "enAudioMimeType": "audio/mpeg" + } +} +``` + ## Testing strategy -Unit tests cover pure functions (SSML formatting, voice-name resolution, triage -JSON normalization, review parsing) with Jest. Runtime integration for HTTP / +Unit tests cover pure functions (TTS request building, voice/model resolution, +text validation, cache writes, triage JSON normalization, review parsing) with +Jest. Runtime integration for HTTP / queue / Cron is verified with `wrangler dev` / `wrangler dev --test-scheduled`. ## few-shot data @@ -142,13 +183,13 @@ the KV namespace and R2 bucket from `wrangler.jsonc`). ### `find-tts-cache` -Searches the TTS cache by SSML body and optionally deletes the matching KV +Searches the TTS cache by spoken text and optionally deletes the matching KV document and R2 audio. KV is read via `wrangler kv key list` / `wrangler kv bulk get` and deleted via `wrangler kv key delete`; R2 audio is removed via `wrangler r2 object delete`. ```bash -npm run find-tts-cache -- "東京" --field ssmlJa +npm run find-tts-cache -- "東京" --field textJa npm run find-tts-cache -- "東京" --delete npm run find-tts-cache -- "東京" --env production --delete ``` diff --git a/scripts/build-secrets-json.mjs b/scripts/build-secrets-json.mjs index b8f1573..c1cb636 100644 --- a/scripts/build-secrets-json.mjs +++ b/scripts/build-secrets-json.mjs @@ -8,7 +8,6 @@ import { readFileSync, writeFileSync } from 'node:fs'; const SECRET_NAMES = [ 'SESSION_JWT_SECRET', - 'AZURE_SPEECH_KEY', 'GOOGLE_PLAY_SA_KEY', 'APPSTORE_CONNECT_KEY', 'OCTOKIT_PAT', diff --git a/src/agent/prompt.test.ts b/src/agent/prompt.test.ts index b36a568..9b17449 100644 --- a/src/agent/prompt.test.ts +++ b/src/agent/prompt.test.ts @@ -72,9 +72,7 @@ describe('buildSystemPrompt', () => { expect(prompt).toContain( 'search_stations_by_name の結果は現在駅からの直通到達性しか保証しない' ); - expect(prompt).toContain( - 'それだけを根拠に駅を乗換地点として扱わない' - ); + expect(prompt).toContain('それだけを根拠に駅を乗換地点として扱わない'); expect(prompt).toContain( '最終目的地への接続を確認できない場合は suggestions を空配列' ); diff --git a/src/cli/find-tts-cache.ts b/src/cli/find-tts-cache.ts index e4cebd3..d26e699 100644 --- a/src/cli/find-tts-cache.ts +++ b/src/cli/find-tts-cache.ts @@ -1,5 +1,5 @@ /** - * KV(TTS_KV) の voice:* メタを SSML 本文で検索し、必要なら KV ドキュメントと + * KV(TTS_KV) の voice:* メタを読み上げ本文で検索し、必要なら KV ドキュメントと * R2 上の音声ファイルを削除する。旧 Firestore+GCS 版の Cloudflare 移植。 * * KV の一覧・値取得・削除、R2 の削除、バケット名解決はすべて wrangler @@ -7,7 +7,7 @@ * ネームスペース ID、R2 認証情報を環境変数で渡す必要はない(要 `wrangler login`)。 * * 例: - * npm run find-tts-cache -- "東京" --field ssmlJa + * npm run find-tts-cache -- "東京" --field textJa * npm run find-tts-cache -- "東京" --delete * npm run find-tts-cache -- "東京" --env production --delete */ @@ -26,7 +26,7 @@ const R2_BINDING = 'TTS_BUCKET'; interface CliArgs { searchTerm: string; - field?: 'ssmlJa' | 'ssmlEn'; + field?: 'textJa' | 'textEn'; exact: boolean; delete: boolean; env?: string; @@ -34,12 +34,12 @@ interface CliArgs { function printUsage(): void { console.error( - 'Usage: npm run find-tts-cache -- [--field ssmlJa|ssmlEn] [--exact] [--delete] [--env ]' + 'Usage: npm run find-tts-cache -- [--field textJa|textEn] [--exact] [--delete] [--env ]' ); console.error(''); console.error('Options:'); console.error( - ' --field 検索対象フィールド(省略時は両方)' + ' --field 検索対象フィールド(省略時は両方)' ); console.error(' --exact 部分一致ではなく完全一致で検索'); console.error(' --delete KV ドキュメントと R2 音声を削除'); @@ -57,7 +57,7 @@ function parseArgs(argv: string[]): CliArgs | null { if (args.length === 0) return null; let searchTerm = ''; - let field: 'ssmlJa' | 'ssmlEn' | undefined; + let field: 'textJa' | 'textEn' | undefined; let exact = false; let deleteMode = false; let env: string | undefined; @@ -66,8 +66,8 @@ function parseArgs(argv: string[]): CliArgs | null { switch (args[i]) { case '--field': { const value = args[++i]; - if (value !== 'ssmlJa' && value !== 'ssmlEn') { - console.error('Error: --field は "ssmlJa" か "ssmlEn" を指定'); + if (value !== 'textJa' && value !== 'textEn') { + console.error('Error: --field は "textJa" か "textEn" を指定'); process.exit(1); } field = value; @@ -141,9 +141,14 @@ async function main(): Promise { if (typeof rec.id !== 'string' || rec.id.length === 0) { continue; } + // ssmlJa/ssmlEn は Azure 時代のレコード。旧エントリも掃除できるよう併せて見る const hit = field - ? matchValue(rec[field]) - : matchValue(rec.ssmlJa) || matchValue(rec.ssmlEn); + ? matchValue(rec[field]) || + matchValue(field === 'textJa' ? rec.ssmlJa : rec.ssmlEn) + : matchValue(rec.textJa) || + matchValue(rec.textEn) || + matchValue(rec.ssmlJa) || + matchValue(rec.ssmlEn); if (hit) matches.push(rec); } @@ -155,8 +160,9 @@ async function main(): Promise { console.log(`${matches.length}件のドキュメントが見つかりました:\n`); for (const rec of matches) { console.log(`ID: ${rec.id}`); - console.log(`SSML (JA): ${rec.ssmlJa ?? ''}`); - console.log(`SSML (EN): ${rec.ssmlEn ?? ''}`); + console.log(`Text (JA): ${rec.textJa ?? rec.ssmlJa ?? ''}`); + console.log(`Text (EN): ${rec.textEn ?? rec.ssmlEn ?? ''}`); + console.log(`Model: ${rec.model ?? ''}`); console.log(`Path (JA): ${rec.pathJa ?? ''}`); console.log(`Path (EN): ${rec.pathEn ?? ''}`); console.log(`Voice (JA): ${rec.voiceJa ?? ''}`); diff --git a/src/cli/lib/wrangler.ts b/src/cli/lib/wrangler.ts index 5a5da49..d264767 100644 --- a/src/cli/lib/wrangler.ts +++ b/src/cli/lib/wrangler.ts @@ -219,6 +219,10 @@ export function confirm(prompt: string): Promise { // --- 共有: voice メタの型 --- export interface VoiceCacheRecord { id: string; + model?: string; + textJa?: string; + textEn?: string; + /** Azure/SSML 時代のレコード。旧エントリを検索・削除できるよう残している */ ssmlJa?: string; ssmlEn?: string; pathJa?: string; diff --git a/src/lib/azure/tts.test.ts b/src/lib/azure/tts.test.ts deleted file mode 100644 index 6f79109..0000000 --- a/src/lib/azure/tts.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { buildAzureSsml } from './tts'; - -describe('buildAzureSsml', () => { - it('wraps standard neural voices with prosody/style when provided', () => { - const ssml = buildAzureSsml('東京', 'ja-JP', 'ja-JP-NanamiNeural', { - pitch: '+1st', - style: 'narration-relaxed', - styleDegree: '1.5', - }); - - expect(ssml).toContain(''); - expect(ssml).toContain( - '' - ); - expect(ssml).toContain('name="ja-JP-NanamiNeural"'); - }); - - it('omits prosody and express-as for HD (DragonHD) voices', () => { - const ssml = buildAzureSsml( - '東京', - 'ja-JP', - 'ja-JP-Nanami:DragonHDLatestNeural', - { pitch: '+1st', style: 'narration-relaxed' } - ); - - // HD は / 非対応のため出力しない - expect(ssml).not.toContain('東京'); - }); - - it('omits prosody for HD English voices as well', () => { - const ssml = buildAzureSsml( - 'Tokyo', - 'en-US', - 'en-US-Jenny:DragonHDLatestNeural', - { pitch: '+1st' } - ); - - expect(ssml).not.toContain(' { - const inner = '3 番線'; - const ssml = buildAzureSsml( - inner, - 'ja-JP', - 'ja-JP-Nanami:DragonHDLatestNeural', - {} - ); - - expect(ssml).toContain(inner); - }); -}); diff --git a/src/lib/azure/tts.ts b/src/lib/azure/tts.ts deleted file mode 100644 index fc4bcd8..0000000 --- a/src/lib/azure/tts.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Azure Speech(Cognitive Services TTS)でテキストを音声に変換する。 - * Azure は SSML 必須。クライアントが送る `` の中身を取り出し、 - * voice/lang/スタイル/プロソディを含む Azure 準拠 SSML に包み直して合成する。出力は MP3。 - */ -import { isAzureHdVoiceName } from '../../utils/ttsVoice'; -import { bytesToBase64 } from '../crypto'; - -// 音質。低ビットレートだと圧縮ノイズで機械っぽく聞こえるため既定を高めにする。 -const DEFAULT_OUTPUT_FORMAT = 'audio-48khz-192kbitrate-mono-mp3'; - -export interface TtsOptions { - /** X-Microsoft-OutputFormat。未指定なら高音質既定 */ - outputFormat?: string; - /** mstts:express-as の style(例: narration-relaxed, customerservice)。未指定なら付けない */ - style?: string; - /** style の強さ(0.01〜2。未指定なら付けない) */ - styleDegree?: string; - /** prosody pitch(例: -2%, +1st)。未指定なら付けない */ - pitch?: string; -} - -/** XML 属性値をエスケープする(", &, <, >, ' を含む値で不正 XML になるのを防ぐ)。 */ -const escapeXmlAttr = (value: string): string => - value - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>') - .replace(/'/g, '''); - -/** クライアント SSML から外側の を剥がして中身だけ返す。 */ -const extractSpeakInner = (ssml: string): string => { - const trimmed = ssml.trim(); - const match = trimmed.match(/^]*>([\s\S]*)<\/speak>$/i); - return (match ? match[1] : trimmed).trim(); -}; - -export const buildAzureSsml = ( - inner: string, - languageCode: string, - voiceName: string, - opts: TtsOptions -): string => { - let content = inner; - - // HD(DragonHD)ボイスは を非対応のため、 - // これらの装飾を付けると合成エラー・無視の原因になる。HD では出力しない。 - const isHd = isAzureHdVoiceName(voiceName); - - if (!isHd && opts.pitch) { - content = `${content}`; - } - - if (!isHd && opts.style) { - const degree = opts.styleDegree - ? ` styledegree="${escapeXmlAttr(opts.styleDegree)}"` - : ''; - content = `${content}`; - } - - return ( - '${content}` - ); -}; - -export interface SynthesizedAudio { - /** base64 エンコードされた MP3 */ - audioContent: string; - mimeType: 'audio/mpeg'; -} - -export const synthesizeSpeech = async ( - region: string, - subscriptionKey: string, - ssml: string, - languageCode: string, - voiceName: string, - opts: TtsOptions = {} -): Promise => { - const inner = extractSpeakInner(ssml); - const body = buildAzureSsml(inner, languageCode, voiceName, opts); - - const url = `https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`; - const res = await fetch(url, { - method: 'POST', - headers: { - 'Ocp-Apim-Subscription-Key': subscriptionKey, - 'Content-Type': 'application/ssml+xml', - 'X-Microsoft-OutputFormat': opts.outputFormat || DEFAULT_OUTPUT_FORMAT, - 'User-Agent': 'trainlcd-worker', - }, - body, - signal: AbortSignal.timeout(30000), - }); - - if (!res.ok) { - const detail = await res.text().catch(() => ''); - throw new Error( - `Azure TTS returned ${res.status}: ${detail.slice(0, 500)}` - ); - } - - const buf = await res.arrayBuffer(); - return { audioContent: bytesToBase64(buf), mimeType: 'audio/mpeg' }; -}; diff --git a/src/lib/openai/tts.test.ts b/src/lib/openai/tts.test.ts new file mode 100644 index 0000000..26f0255 --- /dev/null +++ b/src/lib/openai/tts.test.ts @@ -0,0 +1,168 @@ +import { + buildSpeechRequestBody, + buildSpeechUrl, + normalizeResponseFormat, + parseSpeed, +} from './tts'; + +describe('buildSpeechUrl', () => { + it('targets the OpenAI API directly when no gateway is configured', () => { + expect(buildSpeechUrl()).toBe('https://api.openai.com/v1/audio/speech'); + expect(buildSpeechUrl('')).toBe('https://api.openai.com/v1/audio/speech'); + }); + + it('routes through the AI Gateway when configured', () => { + expect(buildSpeechUrl('https://gateway.example.com/v1/acct/gw')).toBe( + 'https://gateway.example.com/v1/acct/gw/openai/v1/audio/speech' + ); + }); + + it('tolerates trailing slashes on the gateway base url', () => { + expect(buildSpeechUrl('https://gateway.example.com/v1/acct/gw///')).toBe( + 'https://gateway.example.com/v1/acct/gw/openai/v1/audio/speech' + ); + }); +}); + +describe('buildSpeechRequestBody', () => { + it('sends the plain text as input with the model and voice', () => { + expect( + buildSpeechRequestBody({ + model: 'gpt-4o-mini-tts', + voiceName: 'nova', + text: '次は、オオサキです', + }) + ).toEqual({ + model: 'gpt-4o-mini-tts', + voice: 'nova', + input: '次は、オオサキです', + response_format: 'mp3', + }); + }); + + it('passes instructions through when provided', () => { + // gpt-4o-mini-tts は SSML 非対応で、読み方は instructions で指示する + const body = buildSpeechRequestBody({ + model: 'gpt-4o-mini-tts', + voiceName: 'nova', + text: 'The next station is Osaki.', + opts: { instructions: 'calm female announcer' }, + }); + expect(body.instructions).toBe('calm female announcer'); + }); + + it('omits optional fields that are not set', () => { + const body = buildSpeechRequestBody({ + model: 'gpt-4o-mini-tts', + voiceName: 'nova', + text: 'test', + opts: {}, + }); + expect(body).not.toHaveProperty('instructions'); + expect(body).not.toHaveProperty('speed'); + }); + + it('honors a custom response format and sends speed as a number', () => { + // OpenAI の speed は number。文字列で送るとスキーマ検証に弾かれる + const body = buildSpeechRequestBody({ + model: 'gpt-4o-mini-tts', + voiceName: 'nova', + text: 'test', + opts: { responseFormat: 'wav', speed: 1.1 }, + }); + expect(body.response_format).toBe('wav'); + expect(body.speed).toBe(1.1); + expect(typeof body.speed).toBe('number'); + }); + + it('omits an out-of-range speed rather than sending an invalid value', () => { + for (const speed of [0.1, 4.5, Number.NaN, Number.POSITIVE_INFINITY]) { + const body = buildSpeechRequestBody({ + model: 'gpt-4o-mini-tts', + voiceName: 'nova', + text: 'test', + opts: { speed }, + }); + expect(body).not.toHaveProperty('speed'); + } + }); + + it('falls back to mp3 for an unknown or mis-cased response format', () => { + // 環境変数由来の任意文字列をそのまま送ると OpenAI が 400 を返す + expect( + buildSpeechRequestBody({ + model: 'gpt-4o-mini-tts', + voiceName: 'nova', + text: 'test', + opts: { responseFormat: 'MP3' }, + }).response_format + ).toBe('mp3'); + expect( + buildSpeechRequestBody({ + model: 'gpt-4o-mini-tts', + voiceName: 'nova', + text: 'test', + opts: { responseFormat: 'wma' }, + }).response_format + ).toBe('mp3'); + }); + + it('drops instructions for models that do not support them', () => { + // tts-1 / tts-1-hd に instructions を送ると OpenAI が 400 を返し、 + // /tts 全体が失敗する + for (const model of ['tts-1', 'tts-1-hd']) { + const body = buildSpeechRequestBody({ + model, + voiceName: 'nova', + text: 'test', + opts: { instructions: 'calm female announcer' }, + }); + expect(body).not.toHaveProperty('instructions'); + } + + expect( + buildSpeechRequestBody({ + model: 'gpt-4o-mini-tts', + voiceName: 'nova', + text: 'test', + opts: { instructions: 'calm female announcer' }, + }).instructions + ).toBe('calm female announcer'); + }); +}); + +describe('normalizeResponseFormat', () => { + it('accepts the known formats', () => { + for (const format of ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm']) { + expect(normalizeResponseFormat(format)).toBe(format); + } + }); + + it('normalizes case and falls back to mp3 for unknown values', () => { + expect(normalizeResponseFormat('WAV')).toBe('wav'); + expect(normalizeResponseFormat(' Opus ')).toBe('opus'); + expect(normalizeResponseFormat('wma')).toBe('mp3'); + expect(normalizeResponseFormat('')).toBe('mp3'); + expect(normalizeResponseFormat(undefined)).toBe('mp3'); + }); +}); + +describe('parseSpeed', () => { + it('parses a numeric string from the environment', () => { + expect(parseSpeed('1.1')).toBe(1.1); + expect(parseSpeed(' 0.25 ')).toBe(0.25); + expect(parseSpeed('4')).toBe(4); + }); + + it('accepts numbers as-is', () => { + expect(parseSpeed(1.5)).toBe(1.5); + }); + + it('rejects out-of-range and non-numeric values', () => { + expect(parseSpeed('0.24')).toBeUndefined(); + expect(parseSpeed('4.01')).toBeUndefined(); + expect(parseSpeed('fast')).toBeUndefined(); + expect(parseSpeed('')).toBeUndefined(); + expect(parseSpeed(undefined)).toBeUndefined(); + }); +}); diff --git a/src/lib/openai/tts.ts b/src/lib/openai/tts.ts new file mode 100644 index 0000000..ace45da --- /dev/null +++ b/src/lib/openai/tts.ts @@ -0,0 +1,148 @@ +/** + * OpenAI Audio Speech API(gpt-4o-mini-tts)でテキストを音声に変換する。 + * SSML は非対応で、代わりに `instructions` で声色・速度・間の取り方を指示する。 + * 出力は MP3。 + */ +import { bytesToBase64 } from '../crypto'; + +const OPENAI_API_BASE_URL = 'https://api.openai.com'; +const SPEECH_PATH = '/v1/audio/speech'; + +// 合成は数秒で返るが、詰まったときに Worker の CPU/実行時間を食い潰さないよう +// Azure 時代と同じ上限で打ち切る。 +const REQUEST_TIMEOUT_MS = 30_000; + +// AI Gateway 経由時も読み上げ本文をゲートウェイのログに保存させない(設計: プライバシー)。 +// agent/llm.ts と同じ方針。 +const GATEWAY_HEADERS = { 'cf-aig-collect-log-payload': 'false' } as const; + +export interface TtsOptions { + /** 読み方の指示(instructions 対応モデルのみ)。未指定なら付けない */ + instructions?: string; + /** 応答フォーマット。未指定なら mp3 */ + responseFormat?: string; + /** 読み上げ速度(0.25〜4.0)。未指定なら付けない。API は数値を要求する */ + speed?: number; +} + +export interface SynthesizeSpeechParams { + apiKey: string; + /** Cloudflare AI Gateway のベース URL。未指定なら OpenAI へ直行 */ + gatewayBaseUrl?: string; + model: string; + voiceName: string; + /** 読み上げるプレーンテキスト */ + text: string; + opts?: TtsOptions; +} + +export interface SynthesizedAudio { + /** base64 エンコードされた音声 */ + audioContent: string; + mimeType: string; +} + +// response_format と実際に返る Content-Type の対応。応答ヘッダーが欠けていても +// アプリ側が拡張子を判定できるよう、こちらで確定させる。 +const MIME_BY_FORMAT: Record = { + mp3: 'audio/mpeg', + opus: 'audio/opus', + aac: 'audio/aac', + flac: 'audio/flac', + wav: 'audio/wav', + pcm: 'audio/pcm;rate=24000', +}; + +// instructions を受け付けないモデル。旧 tts-1 系に instructions を送ると +// OpenAI が 400 を返し、/tts 全体が失敗するため送信対象から外す。 +const INSTRUCTIONS_UNSUPPORTED_MODELS = new Set(['tts-1', 'tts-1-hd']); + +export const modelSupportsInstructions = (model: string): boolean => + !INSTRUCTIONS_UNSUPPORTED_MODELS.has(model.trim().toLowerCase()); + +/** + * 応答フォーマットを既知の値へ正規化する。環境変数由来の任意文字列(`MP3` の + * ような大文字や誤字)をそのまま送ると OpenAI が 400 を返し、MIME も引けなくなる。 + */ +export const normalizeResponseFormat = (format?: string): string => { + const value = format?.trim().toLowerCase() || ''; + return value in MIME_BY_FORMAT ? value : 'mp3'; +}; + +/** + * 読み上げ速度を数値へ正規化する。環境変数は文字列なので、そのまま送ると + * API のスキーマ検証(number)に弾かれる。範囲外・非数は未指定として扱う。 + */ +export const parseSpeed = (speed?: string | number): number | undefined => { + if (speed === undefined || speed === null || speed === '') { + return undefined; + } + const parsed = typeof speed === 'number' ? speed : Number(speed.trim()); + if (!Number.isFinite(parsed)) { + return undefined; + } + return parsed >= 0.25 && parsed <= 4.0 ? parsed : undefined; +}; + +/** リクエストの送信先を組み立てる。Gateway 指定時は末尾スラッシュの揺れを吸収する。 */ +export const buildSpeechUrl = (gatewayBaseUrl?: string): string => { + const gateway = gatewayBaseUrl?.replace(/\/+$/, '') || ''; + return gateway + ? `${gateway}/openai${SPEECH_PATH}` + : `${OPENAI_API_BASE_URL}${SPEECH_PATH}`; +}; + +/** OpenAI へ送るリクエストボディを組み立てる。 */ +export const buildSpeechRequestBody = (params: { + model: string; + voiceName: string; + text: string; + opts?: TtsOptions; +}): Record => { + const { model, voiceName, text, opts = {} } = params; + const speed = parseSpeed(opts.speed); + return { + model, + voice: voiceName, + input: text, + response_format: normalizeResponseFormat(opts.responseFormat), + ...(opts.instructions && modelSupportsInstructions(model) + ? { instructions: opts.instructions } + : {}), + ...(speed !== undefined ? { speed } : {}), + }; +}; + +export const synthesizeSpeech = async ( + params: SynthesizeSpeechParams +): Promise => { + const { apiKey, gatewayBaseUrl, model, voiceName, text, opts = {} } = params; + + const res = await fetch(buildSpeechUrl(gatewayBaseUrl), { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'User-Agent': 'trainlcd-worker', + ...(gatewayBaseUrl ? GATEWAY_HEADERS : {}), + }, + body: JSON.stringify( + buildSpeechRequestBody({ model, voiceName, text, opts }) + ), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (!res.ok) { + const detail = await res.text().catch(() => ''); + throw new Error( + `OpenAI TTS returned ${res.status}: ${detail.slice(0, 500)}` + ); + } + + const format = normalizeResponseFormat(opts.responseFormat); + const buf = await res.arrayBuffer(); + return { + audioContent: bytesToBase64(buf), + mimeType: MIME_BY_FORMAT[format], + }; +}; diff --git a/src/lib/ttsCache.test.ts b/src/lib/ttsCache.test.ts new file mode 100644 index 0000000..a7a52a1 --- /dev/null +++ b/src/lib/ttsCache.test.ts @@ -0,0 +1,127 @@ +import type { Env } from '../types'; +import { writeTtsCache } from './ttsCache'; + +const createEnv = () => { + const put = jest.fn().mockResolvedValue(undefined); + const kvPut = jest.fn().mockResolvedValue(undefined); + return { + env: { + TTS_BUCKET: { put }, + TTS_KV: { put: kvPut }, + } as unknown as Env, + put, + kvPut, + }; +}; + +const basePayload = { + id: 'abc123', + model: 'gpt-4o-mini-tts', + jaAudioContent: 'QQ==', + enAudioContent: 'QQ==', + jaAudioMimeType: 'audio/mpeg', + enAudioMimeType: 'audio/mpeg', + textJa: '次は、オオサキです', + textEn: 'The next station is Osaki.', + voiceJa: 'nova', + voiceEn: 'nova', +}; + +describe('writeTtsCache', () => { + it('stores both languages and records the metadata', async () => { + const { env, put, kvPut } = createEnv(); + + await writeTtsCache(basePayload, env); + + expect(put).toHaveBeenCalledTimes(2); + expect(put.mock.calls[0][0]).toBe('caches/tts/ja/abc123.mp3'); + expect(put.mock.calls[1][0]).toBe('caches/tts/en/abc123.mp3'); + + const meta = JSON.parse(kvPut.mock.calls[0][1]); + expect(kvPut.mock.calls[0][0]).toBe('voice:abc123'); + expect(meta).toEqual( + expect.objectContaining({ + id: 'abc123', + model: 'gpt-4o-mini-tts', + pathJa: 'caches/tts/ja/abc123.mp3', + pathEn: 'caches/tts/en/abc123.mp3', + textJa: '次は、オオサキです', + textEn: 'The next station is Osaki.', + }) + ); + }); + + it('stores only the language that was synthesized', async () => { + // ユーザーが英語を無効にしている場合、英語は合成もキャッシュもしない + const { env, put, kvPut } = createEnv(); + + await writeTtsCache( + { + ...basePayload, + enAudioContent: undefined, + enAudioMimeType: undefined, + textEn: '', + voiceEn: undefined, + }, + env + ); + + expect(put).toHaveBeenCalledTimes(1); + expect(put.mock.calls[0][0]).toBe('caches/tts/ja/abc123.mp3'); + + const meta = JSON.parse(kvPut.mock.calls[0][1]); + expect(meta.pathJa).toBe('caches/tts/ja/abc123.mp3'); + expect(meta).not.toHaveProperty('pathEn'); + expect(meta).not.toHaveProperty('textEn'); + }); + + it('picks the file extension from the mime type', async () => { + const { env, put } = createEnv(); + + await writeTtsCache( + { + ...basePayload, + jaAudioMimeType: 'audio/wav', + enAudioMimeType: 'audio/pcm;rate=24000', + }, + env + ); + + expect(put.mock.calls[0][0]).toBe('caches/tts/ja/abc123.wav'); + expect(put.mock.calls[1][0]).toBe('caches/tts/en/abc123.pcm'); + }); + + it.each([ + ['audio/opus', 'opus'], + ['audio/aac', 'aac'], + ['audio/flac', 'flac'], + ])('stores %s as .%s', async (mimeType, ext) => { + // TTS_RESPONSE_FORMAT は opus/aac/flac も受け付けるため拡張子も合わせる + const { env, put } = createEnv(); + + await writeTtsCache( + { ...basePayload, jaAudioMimeType: mimeType, enAudioMimeType: mimeType }, + env + ); + + expect(put.mock.calls[0][0]).toBe(`caches/tts/ja/abc123.${ext}`); + expect(put.mock.calls[1][0]).toBe(`caches/tts/en/abc123.${ext}`); + }); + + it('writes nothing when no audio was produced', async () => { + const { env, put, kvPut } = createEnv(); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + + await writeTtsCache( + { + id: 'abc123', + model: 'gpt-4o-mini-tts', + }, + env + ); + + expect(put).not.toHaveBeenCalled(); + expect(kvPut).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); +}); diff --git a/src/lib/ttsCache.ts b/src/lib/ttsCache.ts index c1c5b3e..4707601 100644 --- a/src/lib/ttsCache.ts +++ b/src/lib/ttsCache.ts @@ -2,14 +2,24 @@ * 合成済み TTS 音声を R2 に保存し、メタを KV に書き込む。 * Queues のメッセージ上限(128KB)に音声が収まらないため、キューを介さず * /tts ハンドラから ctx.waitUntil で直接呼ぶ。 + * + * 日英どちらか片方だけの合成もありうる(ユーザーが無効にしている言語は + * そもそも合成しない)ため、届いた言語だけを保存する。 */ import type { Env, TtsCachePayload } from '../types'; import { base64ToBytes } from './crypto'; -const getCacheFileExtension = (mimeType: string): 'mp3' | 'wav' | 'pcm' => { +type CacheFileExtension = 'mp3' | 'wav' | 'opus' | 'aac' | 'flac' | 'pcm'; + +// TTS_RESPONSE_FORMAT は opus / aac / flac も受け付けるため、R2 の拡張子も +// 形式に合わせる。判定できない場合のみ生 PCM 扱いにする。 +const getCacheFileExtension = (mimeType: string): CacheFileExtension => { const normalized = mimeType.toLowerCase(); if (normalized.includes('mpeg') || normalized.includes('mp3')) return 'mp3'; if (normalized.includes('wav')) return 'wav'; + if (normalized.includes('opus')) return 'opus'; + if (normalized.includes('aac')) return 'aac'; + if (normalized.includes('flac')) return 'flac'; return 'pcm'; }; @@ -23,13 +33,14 @@ export const writeTtsCache = async ( enAudioContent, jaAudioMimeType, enAudioMimeType, - ssmlJa, - ssmlEn, + textJa, + textEn, + model, voiceJa, voiceEn, } = payload; - if (!id || !jaAudioContent || !enAudioContent) { + if (!id || (!jaAudioContent && !enAudioContent)) { console.error('Invalid payload for tts cache', { hasId: !!id, hasJa: !!jaAudioContent, @@ -38,32 +49,49 @@ export const writeTtsCache = async ( return; } - const jaContentType = jaAudioMimeType || 'audio/pcm'; - const enContentType = enAudioMimeType || 'audio/pcm'; - const jaPath = `caches/tts/ja/${id}.${getCacheFileExtension(jaContentType)}`; - const enPath = `caches/tts/en/${id}.${getCacheFileExtension(enContentType)}`; + const jaContentType = jaAudioMimeType || 'audio/mpeg'; + const enContentType = enAudioMimeType || 'audio/mpeg'; + const jaPath = jaAudioContent + ? `caches/tts/ja/${id}.${getCacheFileExtension(jaContentType)}` + : undefined; + const enPath = enAudioContent + ? `caches/tts/en/${id}.${getCacheFileExtension(enContentType)}` + : undefined; await Promise.all([ - env.TTS_BUCKET.put(jaPath, base64ToBytes(jaAudioContent), { - httpMetadata: { contentType: jaContentType }, - }), - env.TTS_BUCKET.put(enPath, base64ToBytes(enAudioContent), { - httpMetadata: { contentType: enContentType }, - }), + jaAudioContent && jaPath + ? env.TTS_BUCKET.put(jaPath, base64ToBytes(jaAudioContent), { + httpMetadata: { contentType: jaContentType }, + }) + : null, + enAudioContent && enPath + ? env.TTS_BUCKET.put(enPath, base64ToBytes(enAudioContent), { + httpMetadata: { contentType: enContentType }, + }) + : null, ]); await env.TTS_KV.put( `voice:${id}`, JSON.stringify({ id, - ssmlJa, - pathJa: jaPath, - jaAudioMimeType: jaContentType, - voiceJa, - ssmlEn, - pathEn: enPath, - enAudioMimeType: enContentType, - voiceEn, + model, + ...(jaPath + ? { + textJa, + pathJa: jaPath, + jaAudioMimeType: jaContentType, + voiceJa, + } + : {}), + ...(enPath + ? { + textEn, + pathEn: enPath, + enAudioMimeType: enContentType, + voiceEn, + } + : {}), createdAt: new Date().toISOString(), }) ); diff --git a/src/routes/tts.test.ts b/src/routes/tts.test.ts new file mode 100644 index 0000000..27ca74b --- /dev/null +++ b/src/routes/tts.test.ts @@ -0,0 +1,137 @@ +import { CallableError } from '../lib/callable'; +import { utf8ByteLength } from '../utils/ssml'; +import { computeId, parseTtsText, resolveInstructions } from './tts'; + +describe('parseTtsText', () => { + it('returns the trimmed text', () => { + expect(parseTtsText(' 次は、オオサキです ', 'textJa')).toBe( + '次は、オオサキです' + ); + }); + + it('treats undefined/null/empty as "language not requested"', () => { + // 合成は文字数課金のため、アプリは無効な言語を送ってこない + expect(parseTtsText(undefined, 'textJa')).toBe(''); + expect(parseTtsText(null, 'textJa')).toBe(''); + expect(parseTtsText(' ', 'textJa')).toBe(''); + }); + + it('rejects non-string values', () => { + expect(() => parseTtsText(42, 'textJa')).toThrow(CallableError); + expect(() => parseTtsText({}, 'textEn')).toThrow(/must be a string/); + }); + + it('strips tags so stray SSML is never read aloud', () => { + // gpt-4o-mini-tts は SSML を解釈せずタグをそのまま読み上げてしまう + expect( + parseTtsText('次は大崎です', 'textJa') + ).toBe('次はオオサキです'); + }); + + it('leaves plain text untouched', () => { + expect(parseTtsText('The next station is Osaki, J-Y 24.', 'textEn')).toBe( + 'The next station is Osaki, J-Y 24.' + ); + }); + + it('rejects text beyond the byte limit', () => { + // 日本語は 1 文字 3 バイトなので 4000 バイト超はすぐ作れる + const long = 'あ'.repeat(1400); + expect(() => parseTtsText(long, 'textJa')).toThrow(/byte limit/); + }); + + it('measures the limit in bytes, not characters', () => { + // 1300 文字 = 3900 バイトなので通る + expect(parseTtsText('あ'.repeat(1300), 'textJa')).toHaveLength(1300); + }); +}); + +describe('resolveInstructions', () => { + it('prefers the requested instructions', () => { + expect(resolveInstructions('requested', 'configured', 'fallback')).toBe( + 'requested' + ); + }); + + it('falls back to KV config, then to the env default', () => { + expect(resolveInstructions(undefined, 'configured', 'fallback')).toBe( + 'configured' + ); + expect(resolveInstructions(undefined, undefined, 'fallback')).toBe( + 'fallback' + ); + expect(resolveInstructions(' ', ' ', 'fallback')).toBe('fallback'); + }); + + it('returns an empty string when nothing is configured', () => { + expect(resolveInstructions(undefined, undefined, undefined)).toBe(''); + }); + + it('ignores non-string requests', () => { + expect(resolveInstructions(42, undefined, 'fallback')).toBe('fallback'); + }); + + it('truncates rather than failing when the instructions are too long', () => { + // 読み方の指示は本文ではないため、長すぎても放送そのものは落とさない + const result = resolveInstructions('x'.repeat(5000), undefined, undefined); + expect(utf8ByteLength(result)).toBe(2000); + }); + + it('truncates by UTF-8 bytes, not characters', () => { + // 日本語は 1 文字 3 バイト。文字数で切ると上限を守れない + const result = resolveInstructions('あ'.repeat(3000), undefined, undefined); + expect(utf8ByteLength(result)).toBeLessThanOrEqual(2000); + expect(result).toHaveLength(666); + }); + + it('does not split surrogate pairs when truncating', () => { + const result = resolveInstructions('🚃'.repeat(1000), undefined, undefined); + expect(utf8ByteLength(result)).toBeLessThanOrEqual(2000); + expect(result).not.toMatch(/\uFFFD/); + expect([...result].every((char) => char === '🚃')).toBe(true); + }); +}); + +describe('computeId', () => { + const base = { + enVoiceName: 'nova', + instructionsEn: 'calm', + instructionsJa: '落ち着いて', + jaVoiceName: 'nova', + model: 'gpt-4o-mini-tts', + responseFormat: 'mp3', + speed: null as number | null, + textEn: 'The next station is Osaki.', + textJa: '次は、オオサキです', + }; + + it('is stable for identical input', async () => { + expect(await computeId(base)).toBe(await computeId(base)); + }); + + it.each([ + ['textJa', { textJa: '次は、シンジュクです' }], + ['textEn', { textEn: 'The next station is Shinjuku.' }], + ['model', { model: 'tts-1' }], + ['jaVoiceName', { jaVoiceName: 'shimmer' }], + ['enVoiceName', { enVoiceName: 'shimmer' }], + ['instructionsJa', { instructionsJa: '明るく' }], + ['instructionsEn', { instructionsEn: 'bright' }], + // responseFormat / speed はかつてネストしたオブジェクトに置いていたため、 + // JSON.stringify の配列 replacer に落とされて ID に反映されていなかった + ['responseFormat', { responseFormat: 'wav' }], + ['speed', { speed: 1.25 }], + ])('changes when %s changes', async (_name, override) => { + expect(await computeId({ ...base, ...override })).not.toBe( + await computeId(base) + ); + }); + + it('distinguishes single-language requests from bilingual ones', async () => { + // 片言語リクエストが両言語のキャッシュへヒットしないこと + const jaOnly = await computeId({ ...base, textEn: '' }); + const enOnly = await computeId({ ...base, textJa: '' }); + const both = await computeId(base); + expect(new Set([jaOnly, enOnly, both]).size).toBe(3); + }); +}); diff --git a/src/routes/tts.ts b/src/routes/tts.ts index be2b71f..ef3ee1e 100644 --- a/src/routes/tts.ts +++ b/src/routes/tts.ts @@ -1,28 +1,39 @@ -/** POST /tts — Azure Speech で音声合成し、KV/R2 キャッシュを介して返す(callable 互換)。 */ +/** POST /tts — OpenAI(gpt-4o-mini-tts) で音声合成し、KV/R2 キャッシュを介して返す(callable 互換)。 */ import { verifySessionToken } from '../lib/auth/session'; -import { synthesizeSpeech, type TtsOptions } from '../lib/azure/tts'; import { CallableError, callableSuccess, parseCallableData, } from '../lib/callable'; import { bytesToBase64, sha256Hex } from '../lib/crypto'; +import { + normalizeResponseFormat, + parseSpeed, + synthesizeSpeech, + type TtsOptions, +} from '../lib/openai/tts'; import { writeTtsCache } from '../lib/ttsCache'; import type { Env } from '../types'; import { normalizeRomanText } from '../utils/normalize'; -import { stripSsml, utf8ByteLength } from '../utils/ssml'; -import { resolveAzureVoiceName } from '../utils/ttsVoice'; +import { stripSsml, truncateToByteLimit, utf8ByteLength } from '../utils/ssml'; +import { resolveOpenAiVoiceName, resolveTtsModel } from '../utils/ttsVoice'; interface TtsRequest { - ssmlJa?: unknown; - ssmlEn?: unknown; + textJa?: unknown; + textEn?: unknown; + model?: unknown; jaVoiceName?: unknown; enVoiceName?: unknown; + instructionsJa?: unknown; + instructionsEn?: unknown; } interface TtsConfig { + model?: string; jaVoiceName?: string; enVoiceName?: string; + instructionsJa?: string; + instructionsEn?: string; } interface VoiceCacheMeta { @@ -33,8 +44,10 @@ interface VoiceCacheMeta { } const TEXT_BYTE_LIMIT = 4000; -const RAW_SSML_BYTE_LIMIT = 10000; -const HASH_VERSION = 12; +// 読み方の指示は声色の調整用で、長文を受ける必要はない。無制限に受けると +// リクエストサイズとキャッシュキーが無駄に膨らむため上限を設ける。 +const INSTRUCTIONS_BYTE_LIMIT = 2000; +const HASH_VERSION = 13; const TTS_CONFIG_CACHE_TTL_MS = 5 * 60 * 1000; let ttsConfigCache: { data: TtsConfig; fetchedAt: number } | null = null; @@ -57,26 +70,73 @@ const getTtsConfig = async (env: Env): Promise => { } }; -const computeId = async (payload: { +// JSON.stringify の第2引数に配列を渡すと「その名前のキーだけ」を全階層で +// 直列化する。ネストしたオプションは名前がリストに無いと丸ごと落ちるため、 +// キャッシュキーへ含めたい値はすべてトップレベルへ平坦化して渡すこと。 +export const computeId = async (payload: { enVoiceName: string; + instructionsEn: string; + instructionsJa: string; jaVoiceName: string; - ssmlEn: string; - ssmlJa: string; - ttsOptions: TtsOptions; + model: string; + responseFormat: string; + speed: number | null; + textEn: string; + textJa: string; }): Promise => { const obj = { ...payload, version: HASH_VERSION } as const; const hashPayload = JSON.stringify(obj, Object.keys(obj).sort()); return sha256Hex(hashPayload); }; -const requireString = (value: unknown, name: string): string => { - if (typeof value !== 'string' || value.length === 0) { +/** + * 読み上げ対象テキストを受け取り、検証済みのプレーンテキストを返す。 + * 未指定・空文字は「その言語を要求しない」を意味する(合成は文字数課金のため、 + * アプリはユーザーが無効にしている言語を送ってこない)。 + */ +export const parseTtsText = (value: unknown, name: string): string => { + if (value === undefined || value === null) { + return ''; + } + if (typeof value !== 'string') { throw new CallableError( 'invalid-argument', - `The function must be called with one argument "${name}" containing the message to add.` + `"${name}" must be a string if provided` ); } - return value; + // gpt-4o-mini-tts は SSML を解釈せずタグをそのまま読み上げるため、万一 + // タグが紛れ込んでも読ませない。プレーンテキストには実質作用しない。 + const stripped = stripSsml(value).trim(); + if (stripped.length === 0) { + return ''; + } + + const bytes = utf8ByteLength(stripped); + if (bytes > TEXT_BYTE_LIMIT) { + throw new CallableError( + 'invalid-argument', + `${name} exceeds ${TEXT_BYTE_LIMIT} byte limit (${bytes} bytes)` + ); + } + return stripped; +}; + +/** 読み方の指示を リクエスト → KV 設定 → 環境変数 の順で解決する。 */ +export const resolveInstructions = ( + requested: unknown, + configured: string | undefined, + fallback: string | undefined +): string => { + const value = + typeof requested === 'string' && requested.trim().length > 0 + ? requested.trim() + : configured?.trim() || fallback?.trim() || ''; + if (!value) { + return ''; + } + // 上限超過は弾かずに切り詰める。読み方の指示は本文ではないため、 + // これだけで放送そのものを失敗させる必要はない。 + return truncateToByteLimit(value, INSTRUCTIONS_BYTE_LIMIT); }; export const handleTts = async ( @@ -88,107 +148,96 @@ export const handleTts = async ( const data = await parseCallableData(req); - const ssmlJa = requireString(data.ssmlJa, 'ssmlJa'); - // 生入力を保持し、バイト数上限は正規化前の値で判定する(正規化での展開/削除で - // 本来通る入力を弾いたり、上限超え入力を通したりしないため)。 - const rawSsmlEn = requireString(data.ssmlEn, 'ssmlEn'); - const ssmlEn = normalizeRomanText(rawSsmlEn); - if (ssmlEn.trim().length === 0) { + const textJa = parseTtsText(data.textJa, 'textJa'); + // 英語は駅名の表記ゆれ(全角記号・略記・長音符・大文字表記)を吸収してから合成する + const textEn = normalizeRomanText(parseTtsText(data.textEn, 'textEn')).trim(); + + const wantsJa = textJa.length > 0; + const wantsEn = textEn.length > 0; + if (!wantsJa && !wantsEn) { throw new CallableError( 'invalid-argument', - 'The function must be called with one argument "ssmlEn" containing the message to add.' + 'The function must be called with at least one of "textJa" or "textEn" containing the text to speak.' + ); + } + + if (!env.OPENAI_API_KEY) { + throw new CallableError( + 'failed-precondition', + 'OPENAI_API_KEY is not configured' ); } const ttsConfig = await getTtsConfig(env); - const jaVoiceName = resolveAzureVoiceName( + const model = resolveTtsModel(data.model, ttsConfig.model, env.TTS_MODEL); + const jaVoiceName = resolveOpenAiVoiceName( data.jaVoiceName, ttsConfig.jaVoiceName, env.TTS_JA_VOICE_NAME ); - const enVoiceName = resolveAzureVoiceName( + const enVoiceName = resolveOpenAiVoiceName( data.enVoiceName, ttsConfig.enVoiceName, env.TTS_EN_VOICE_NAME ); + const instructionsJa = resolveInstructions( + data.instructionsJa, + ttsConfig.instructionsJa, + env.TTS_INSTRUCTIONS_JA + ); + const instructionsEn = resolveInstructions( + data.instructionsEn, + ttsConfig.instructionsEn, + env.TTS_INSTRUCTIONS_EN + ); - const strippedJa = stripSsml(ssmlJa); - const strippedEn = stripSsml(ssmlEn); - if (strippedJa.trim().length === 0) { - throw new CallableError( - 'invalid-argument', - 'ssmlJa contains no visible text after stripping SSML tags' - ); - } - if (strippedEn.trim().length === 0) { - throw new CallableError( - 'invalid-argument', - 'ssmlEn contains no visible text after stripping SSML tags' - ); - } - - const jaTextBytes = utf8ByteLength(strippedJa); - const enTextBytes = utf8ByteLength(strippedEn); - if (jaTextBytes > TEXT_BYTE_LIMIT) { - throw new CallableError( - 'invalid-argument', - `ssmlJa text exceeds ${TEXT_BYTE_LIMIT} byte limit (${jaTextBytes} bytes)` - ); - } - if (enTextBytes > TEXT_BYTE_LIMIT) { - throw new CallableError( - 'invalid-argument', - `ssmlEn text exceeds ${TEXT_BYTE_LIMIT} byte limit (${enTextBytes} bytes)` - ); - } - - // 可視テキストだけでなく生 SSML のバイト長にも上限を設け、タグ膨張入力を弾く - if ( - utf8ByteLength(ssmlJa) > RAW_SSML_BYTE_LIMIT || - utf8ByteLength(rawSsmlEn) > RAW_SSML_BYTE_LIMIT - ) { - throw new CallableError( - 'invalid-argument', - `raw SSML exceeds ${RAW_SSML_BYTE_LIMIT} byte limit` - ); - } - - // 合成オプションもキャッシュキーに含める(outputFormat/style/styleDegree/pitch を - // 変えたら別の音声になるため、同じ voice:${id} を再利用させない)。 - const ttsOptions: TtsOptions = { - outputFormat: env.AZURE_TTS_OUTPUT_FORMAT || undefined, - style: env.AZURE_TTS_STYLE || undefined, - styleDegree: env.AZURE_TTS_STYLE_DEGREE || undefined, - pitch: env.AZURE_TTS_PITCH || undefined, - }; + // 環境変数は文字列なので、送信前に正規化した値を作る。この正規化後の値を + // そのままキャッシュキーにも使い、設定変更が確実に別 ID になるようにする。 + const responseFormat = normalizeResponseFormat(env.TTS_RESPONSE_FORMAT); + const speed = parseSpeed(env.TTS_SPEED); + const ttsOptions: TtsOptions = { responseFormat, speed }; const id = await computeId({ enVoiceName, + instructionsEn, + instructionsJa, jaVoiceName, - ssmlEn, - ssmlJa, - ttsOptions, + model, + responseFormat, + speed: speed ?? null, + textEn, + textJa, }); // --- キャッシュ照会 --- + // id は「どの言語を要求したか」まで含めて決まるため、要求した言語のパスが + // 揃っていれば同じ組み合わせの再放送とみなせる。 const meta = await env.TTS_KV.get(`voice:${id}`, 'json'); - if (meta?.pathJa && meta.pathEn) { + if (meta && (!wantsJa || meta.pathJa) && (!wantsEn || meta.pathEn)) { try { const [jaObj, enObj] = await Promise.all([ - env.TTS_BUCKET.get(meta.pathJa), - env.TTS_BUCKET.get(meta.pathEn), + wantsJa && meta.pathJa ? env.TTS_BUCKET.get(meta.pathJa) : null, + wantsEn && meta.pathEn ? env.TTS_BUCKET.get(meta.pathEn) : null, ]); - if (jaObj && enObj) { + if ((!wantsJa || jaObj) && (!wantsEn || enObj)) { const [jaBuf, enBuf] = await Promise.all([ - jaObj.arrayBuffer(), - enObj.arrayBuffer(), + jaObj ? jaObj.arrayBuffer() : null, + enObj ? enObj.arrayBuffer() : null, ]); return callableSuccess({ id, - jaAudioContent: bytesToBase64(jaBuf), - enAudioContent: bytesToBase64(enBuf), - jaAudioMimeType: meta.jaAudioMimeType ?? 'audio/mpeg', - enAudioMimeType: meta.enAudioMimeType ?? 'audio/mpeg', + ...(jaBuf + ? { + jaAudioContent: bytesToBase64(jaBuf), + jaAudioMimeType: meta.jaAudioMimeType ?? 'audio/mpeg', + } + : {}), + ...(enBuf + ? { + enAudioContent: bytesToBase64(enBuf), + enAudioMimeType: meta.enAudioMimeType ?? 'audio/mpeg', + } + : {}), }); } } catch (e) { @@ -199,25 +248,30 @@ export const handleTts = async ( } } - // --- 合成(Azure) --- - // 音質・スタイル・プロソディは env で調整可能(ttsOptions は上で構築済み) + // --- 合成(OpenAI) --- + // 要求された言語だけ合成する(合成は文字数課金) + const gatewayBaseUrl = env.AI_GATEWAY_BASE_URL || undefined; const [jaAudio, enAudio] = await Promise.all([ - synthesizeSpeech( - env.AZURE_SPEECH_REGION, - env.AZURE_SPEECH_KEY, - ssmlJa, - 'ja-JP', - jaVoiceName, - ttsOptions - ), - synthesizeSpeech( - env.AZURE_SPEECH_REGION, - env.AZURE_SPEECH_KEY, - ssmlEn, - 'en-US', - enVoiceName, - ttsOptions - ), + wantsJa + ? synthesizeSpeech({ + apiKey: env.OPENAI_API_KEY, + gatewayBaseUrl, + model, + voiceName: jaVoiceName, + text: textJa, + opts: { ...ttsOptions, instructions: instructionsJa || undefined }, + }) + : null, + wantsEn + ? synthesizeSpeech({ + apiKey: env.OPENAI_API_KEY, + gatewayBaseUrl, + model, + voiceName: enVoiceName, + text: textEn, + opts: { ...ttsOptions, instructions: instructionsEn || undefined }, + }) + : null, ]); // キャッシュ書き込みは非同期(失敗してもユーザー応答に影響させない)。 @@ -226,14 +280,15 @@ export const handleTts = async ( writeTtsCache( { id, - jaAudioContent: jaAudio.audioContent, - enAudioContent: enAudio.audioContent, - jaAudioMimeType: jaAudio.mimeType, - enAudioMimeType: enAudio.mimeType, - ssmlJa, - ssmlEn, - voiceJa: jaVoiceName, - voiceEn: enVoiceName, + jaAudioContent: jaAudio?.audioContent, + enAudioContent: enAudio?.audioContent, + jaAudioMimeType: jaAudio?.mimeType, + enAudioMimeType: enAudio?.mimeType, + textJa, + textEn, + model, + voiceJa: wantsJa ? jaVoiceName : undefined, + voiceEn: wantsEn ? enVoiceName : undefined, }, env ).catch((err) => console.error('Failed to cache tts audio:', err)) @@ -241,9 +296,17 @@ export const handleTts = async ( return callableSuccess({ id, - jaAudioContent: jaAudio.audioContent, - enAudioContent: enAudio.audioContent, - jaAudioMimeType: jaAudio.mimeType, - enAudioMimeType: enAudio.mimeType, + ...(jaAudio + ? { + jaAudioContent: jaAudio.audioContent, + jaAudioMimeType: jaAudio.mimeType, + } + : {}), + ...(enAudio + ? { + enAudioContent: enAudio.audioContent, + enAudioMimeType: enAudio.mimeType, + } + : {}), }); }; diff --git a/src/types.ts b/src/types.ts index ecfa6ec..d46d3dd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,10 +16,15 @@ export interface Env { // --- Vars(非機密。wrangler.jsonc の vars) --- GOOGLE_PLAY_PACKAGE_NAME: string; - AZURE_SPEECH_REGION: string; AI_TRIAGE_MODEL: string; + /** 合成に使う OpenAI TTS モデル(例: gpt-4o-mini-tts) */ + TTS_MODEL: string; + /** OpenAI TTS のボイス名(例: nova)。ボイスは多言語対応のため日英で同じ名前を使える */ TTS_JA_VOICE_NAME: string; TTS_EN_VOICE_NAME: string; + /** 読み方の指示(gpt-4o-mini-tts の instructions)の既定値 */ + TTS_INSTRUCTIONS_JA: string; + TTS_INSTRUCTIONS_EN: string; SESSION_TOKEN_TTL_SECONDS: string; UPLOAD_PUBLIC_BASE_URL: string; FEW_SHOT_KV_KEY: string; @@ -42,7 +47,6 @@ export interface Env { // --- Secrets(wrangler secret put で投入) --- SESSION_JWT_SECRET: string; - AZURE_SPEECH_KEY: string; /** Android Publisher 用 Google サービスアカウント鍵 JSON 文字列 */ GOOGLE_PLAY_SA_KEY: string; /** App Store Connect API 鍵 JSON 文字列 ({keyId, issuerId, privateKey}) */ @@ -58,11 +62,11 @@ export interface Env { /** LangSmith の API キー(dev 環境のトレーシング用・任意) */ LANGSMITH_API_KEY?: string; - // --- Azure TTS チューニング(任意。未設定なら高音質既定のみ適用) --- - AZURE_TTS_OUTPUT_FORMAT?: string; - AZURE_TTS_STYLE?: string; - AZURE_TTS_STYLE_DEGREE?: string; - AZURE_TTS_PITCH?: string; + // --- TTS チューニング(任意。未設定なら mp3・等速) --- + /** OpenAI TTS の response_format(mp3 / opus / aac / flac / wav / pcm) */ + TTS_RESPONSE_FORMAT?: string; + /** 読み上げ速度(0.25〜4.0) */ + TTS_SPEED?: string; // --- 任意のデバッグ変数(未設定可) --- REVIEWS_DEBUG?: string; @@ -71,17 +75,21 @@ export interface Env { APPSTORE_APP_ID?: string; } -/** TTS キャッシュ書き込みのペイロード(R2+KV へ直接保存。キューは介さない) */ +/** + * TTS キャッシュ書き込みのペイロード(R2+KV へ直接保存。キューは介さない)。 + * 片方の言語だけ合成することがあるため、言語ごとのフィールドは任意。 + */ export interface TtsCachePayload { id: string; - jaAudioContent: string; - enAudioContent: string; - jaAudioMimeType: string; - enAudioMimeType: string; - ssmlJa: string; - ssmlEn: string; - voiceJa: string; - voiceEn: string; + model: string; + jaAudioContent?: string; + enAudioContent?: string; + jaAudioMimeType?: string; + enAudioMimeType?: string; + textJa?: string; + textEn?: string; + voiceJa?: string; + voiceEn?: string; } /** feedback-triage キューのメッセージ */ diff --git a/src/utils/normalize.test.ts b/src/utils/normalize.test.ts index a0316aa..e8f3605 100644 --- a/src/utils/normalize.test.ts +++ b/src/utils/normalize.test.ts @@ -10,6 +10,26 @@ describe('utils/normalize.ts', () => { expect(normalizeRomanText('JR Kobe Line')).toBe('J-R Kobe Line'); }); + it('leaves hyphenated initialisms alone', () => { + // アプリ側が「JR」を J-R へ倒してから送ってくるため、ここで J-r へ + // 崩さないこと(= 二重に適用しても結果が変わらない) + expect(normalizeRomanText('J-R Kobe Line')).toBe('J-R Kobe Line'); + expect(normalizeRomanText(normalizeRomanText('JR Kobe Line'))).toBe( + 'J-R Kobe Line' + ); + expect(normalizeRomanText('Osaki, J-Y 24.')).toBe('Osaki, J-Y 24.'); + }); + + it('keeps hyphenated initialisms next to punctuation', () => { + // 文末やカンマの直前でも J-r に崩さない + expect(normalizeRomanText('Please transfer to the J-R.')).toBe( + 'Please transfer to the J-R.' + ); + expect(normalizeRomanText('Transfer to the J-R, and the subway.')).toBe( + 'Transfer to the J-R, and the subway.' + ); + }); + it.each(['Tokyo', 'tOkyo'])('text: %s', (text) => { expect(normalizeRomanText(text)).toBe('Tokyo'); }); diff --git a/src/utils/normalize.ts b/src/utils/normalize.ts index 4e14ee5..90f68fb 100644 --- a/src/utils/normalize.ts +++ b/src/utils/normalize.ts @@ -1,9 +1,19 @@ import { removeMacron } from './removeMacron'; -const capitalizeSegment = (seg: string): string => - /[A-Z]/.test(seg) +// ハイフン区切りの頭字語(J-R / J-Y など)。アプリ側が「JR」を読み間違えられない +// 表記へ倒してから送ってくるため、これを capitalizeSegment に通して "J-r" へ +// 崩されないよう素通しする。文末・カンマ前("J-R." / "J-R,")も対象にするため、 +// セグメント全体一致ではなく後続が英数字でないことを先読みで判定する。 +const HYPHENATED_INITIALISM = /^[A-Z](?:-[A-Z])+(?=$|[^A-Za-z0-9])/; + +const capitalizeSegment = (seg: string): string => { + if (HYPHENATED_INITIALISM.test(seg)) { + return seg; + } + return /[A-Z]/.test(seg) ? seg.charAt(0).toUpperCase() + seg.slice(1).toLowerCase() : seg; +}; // テキストノード(SSML タグの外側)だけに掛ける正規化。タグやその属性値 // ( 等)を壊さないため、タグ部分には適用しない。 diff --git a/src/utils/ssml.ts b/src/utils/ssml.ts index de51b04..fa37a95 100644 --- a/src/utils/ssml.ts +++ b/src/utils/ssml.ts @@ -22,3 +22,26 @@ export const stripSsml = (text: string): string => /** UTF-8 バイト長。 */ export const utf8ByteLength = (s: string): number => new TextEncoder().encode(s).length; + +/** + * UTF-8 バイト数の上限に合わせて切り詰める。 + * 文字数で切ると日本語(1 文字 3 バイト)では上限を守れないため、コードポイント + * 単位で積んでバイト数を数える。壊れた文字やサロゲートペアの分割は起きない。 + */ +export const truncateToByteLimit = (text: string, limit: number): string => { + if (limit <= 0 || utf8ByteLength(text) <= limit) { + return text; + } + + let bytes = 0; + let truncated = ''; + for (const char of text) { + const charBytes = utf8ByteLength(char); + if (bytes + charBytes > limit) { + break; + } + bytes += charBytes; + truncated += char; + } + return truncated; +}; diff --git a/src/utils/ttsVoice.test.ts b/src/utils/ttsVoice.test.ts index ccb0f23..2805b51 100644 --- a/src/utils/ttsVoice.test.ts +++ b/src/utils/ttsVoice.test.ts @@ -1,66 +1,102 @@ import { - isAzureHdVoiceName, - isAzureVoiceName, - resolveAzureVoiceName, + DEFAULT_TTS_MODEL, + DEFAULT_TTS_VOICE, + isOpenAiVoiceName, + isTtsModel, + resolveOpenAiVoiceName, + resolveTtsModel, } from './ttsVoice'; -describe('ttsVoice (Azure)', () => { - it('accepts Azure neural voices', () => { - expect(isAzureVoiceName('ja-JP-NanamiNeural')).toBe(true); - expect(isAzureVoiceName('en-US-JennyNeural')).toBe(true); - expect(isAzureVoiceName('en-US-AvaMultilingualNeural')).toBe(true); +describe('ttsVoice (OpenAI)', () => { + it('accepts OpenAI voice presets', () => { + expect(isOpenAiVoiceName('nova')).toBe(true); + expect(isOpenAiVoiceName('shimmer')).toBe(true); + expect(isOpenAiVoiceName('coral')).toBe(true); + expect(isOpenAiVoiceName('alloy')).toBe(true); }); - it('accepts Azure HD (DragonHD) voices as valid voice names', () => { - expect(isAzureVoiceName('ja-JP-Nanami:DragonHDLatestNeural')).toBe(true); - expect(isAzureVoiceName('en-US-Jenny:DragonHDLatestNeural')).toBe(true); + it('accepts voice names case-insensitively and with surrounding spaces', () => { + expect(isOpenAiVoiceName('Nova')).toBe(true); + expect(isOpenAiVoiceName(' NOVA ')).toBe(true); }); - it('detects HD (DragonHD) voices', () => { - expect(isAzureHdVoiceName('ja-JP-Nanami:DragonHDLatestNeural')).toBe(true); - expect(isAzureHdVoiceName('en-US-Jenny:DragonHDLatestNeural')).toBe(true); - expect(isAzureHdVoiceName('en-US-Ava:DragonHDLatestNeural')).toBe(true); + it('rejects unknown voice ids', () => { + // Azure/Google 時代のボイス名がそのまま送られてきても弾く + expect(isOpenAiVoiceName('ja-JP-NanamiNeural')).toBe(false); + expect(isOpenAiVoiceName('ja-JP-Standard-B')).toBe(false); + expect(isOpenAiVoiceName('')).toBe(false); }); - it('treats standard neural voices as non-HD', () => { - expect(isAzureHdVoiceName('ja-JP-NanamiNeural')).toBe(false); - expect(isAzureHdVoiceName('en-US-JennyNeural')).toBe(false); - expect(isAzureHdVoiceName('')).toBe(false); + it('prefers a valid requested voice', () => { + expect(resolveOpenAiVoiceName('shimmer', 'coral', 'nova')).toBe('shimmer'); }); - it('rejects non-Azure voice ids', () => { - expect(isAzureVoiceName('ja-JP-Standard-B')).toBe(false); - expect(isAzureVoiceName('en-US-Chirp3-HD-Aoede')).toBe(false); - expect(isAzureVoiceName('')).toBe(false); + it('normalizes the resolved voice to lower case', () => { + expect(resolveOpenAiVoiceName('Shimmer', 'coral', 'nova')).toBe('shimmer'); }); - it('prefers a valid requested voice', () => { - expect( - resolveAzureVoiceName( - 'en-US-AriaNeural', - 'en-US-GuyNeural', - 'en-US-JennyNeural' - ) - ).toBe('en-US-AriaNeural'); + it('falls back to a configured voice when the request is invalid', () => { + expect(resolveOpenAiVoiceName('ja-JP-NanamiNeural', 'coral', 'nova')).toBe( + 'coral' + ); }); - it('falls back to a configured voice when the request is invalid', () => { + it('falls back to the default voice when both inputs are invalid', () => { expect( - resolveAzureVoiceName( - 'en-US-Standard-H', - 'en-US-GuyNeural', - 'en-US-JennyNeural' - ) - ).toBe('en-US-GuyNeural'); + resolveOpenAiVoiceName('ja-JP-NanamiNeural', 'en-US-JennyNeural', 'nova') + ).toBe('nova'); }); - it('falls back to the default voice when both inputs are invalid', () => { + it('falls back to the default voice for non-string inputs', () => { + expect(resolveOpenAiVoiceName(undefined, undefined, 'nova')).toBe('nova'); + expect(resolveOpenAiVoiceName(42, {}, 'nova')).toBe('nova'); + }); + + it('validates the env default too, so a stale Azure value never reaches OpenAI', () => { + // 環境変数の設定ミスをそのまま送ると OpenAI が 400 を返し /tts が落ちる expect( - resolveAzureVoiceName( - 'ja-JP-Standard-B', - 'ja-JP-Neural2-B', - 'ja-JP-NanamiNeural' - ) - ).toBe('ja-JP-NanamiNeural'); + resolveOpenAiVoiceName(undefined, undefined, 'ja-JP-NanamiNeural') + ).toBe(DEFAULT_TTS_VOICE); + expect(resolveOpenAiVoiceName(undefined, undefined, '')).toBe( + DEFAULT_TTS_VOICE + ); + }); +}); + +describe('resolveTtsModel', () => { + it('accepts the allowed TTS models', () => { + expect(isTtsModel('gpt-4o-mini-tts')).toBe(true); + expect(isTtsModel('tts-1')).toBe(true); + expect(isTtsModel('tts-1-hd')).toBe(true); + }); + + it('rejects models outside the allowlist', () => { + // クライアントに高額なモデルを名指しさせない + expect(isTtsModel('gpt-4o')).toBe(false); + expect(isTtsModel('gpt-5.6-luna')).toBe(false); + expect(isTtsModel('')).toBe(false); + }); + + it('prefers a valid requested model', () => { + expect(resolveTtsModel('tts-1-hd', 'tts-1', 'gpt-4o-mini-tts')).toBe( + 'tts-1-hd' + ); + }); + + it('falls back through config to the default for disallowed models', () => { + expect(resolveTtsModel('gpt-4o', 'tts-1', 'gpt-4o-mini-tts')).toBe('tts-1'); + expect(resolveTtsModel('gpt-4o', 'gpt-4o', 'gpt-4o-mini-tts')).toBe( + 'gpt-4o-mini-tts' + ); + expect(resolveTtsModel(undefined, undefined, 'gpt-4o-mini-tts')).toBe( + 'gpt-4o-mini-tts' + ); + }); + + it('validates the env default too', () => { + expect(resolveTtsModel(undefined, undefined, 'gpt-4o')).toBe( + DEFAULT_TTS_MODEL + ); + expect(resolveTtsModel(undefined, undefined, '')).toBe(DEFAULT_TTS_MODEL); }); }); diff --git a/src/utils/ttsVoice.ts b/src/utils/ttsVoice.ts index 4324cc4..a37cd09 100644 --- a/src/utils/ttsVoice.ts +++ b/src/utils/ttsVoice.ts @@ -1,42 +1,96 @@ /** - * Azure Speech のニューラルボイス名を扱うユーティリティ。 + * OpenAI TTS のボイス名を扱うユーティリティ。 * - * Azure のボイス id は `-Neural` 形式(例: `ja-JP-NanamiNeural`, - * `en-US-JennyNeural`, `en-US-AvaMultilingualNeural`)。Google の Standard/WaveNet の - * ような価格差はなく、ニューラルが標準ティアのため「コストガード」は不要だが、 - * クライアントから任意文字列が渡るため最低限の妥当性チェックは行う。 + * gpt-4o-mini-tts のボイスは固定の名前付きプリセット(`nova` など)で、Azure の + * ような `-Neural` 形式ではない。ボイスは多言語対応のため日英で + * 同じ名前を使える。クライアントから任意文字列が渡るため、未知の名前は + * そのまま OpenAI へ流さず既定値へ倒す(400 で放送を落とさないため)。 */ -// 標準ニューラル(ja-JP-NanamiNeural)と HD ボイス(ja-JP-Nanami:DragonHDLatestNeural)の両方を許可 -const AZURE_VOICE_PATTERN = /^[a-z]{2,3}-[A-Za-z]+-[A-Za-z0-9:]+Neural$/; -export const isAzureVoiceName = (voiceName: string): boolean => - AZURE_VOICE_PATTERN.test(voiceName); +// OpenAI Audio Speech API が受け付けるボイス。女性寄りは nova / shimmer / coral / sage。 +const OPENAI_VOICES = new Set([ + 'alloy', + 'ash', + 'ballad', + 'coral', + 'echo', + 'fable', + 'nova', + 'onyx', + 'sage', + 'shimmer', + 'verse', +]); -// HD(DragonHD)ボイス判定。HD ボイスは id に `:DragonHD...Neural` を含む -// (例: `ja-JP-Nanami:DragonHDLatestNeural`, `en-US-Jenny:DragonHDLatestNeural`)。 -// HD は を非対応のため、SSML 構築時に -// 未サポート要素を出し分ける用途で使う。 -const AZURE_HD_VOICE_PATTERN = /:DragonHD[A-Za-z0-9]*Neural$/i; +export const isOpenAiVoiceName = (voiceName: string): boolean => + OPENAI_VOICES.has(voiceName.trim().toLowerCase()); -export const isAzureHdVoiceName = (voiceName: string): boolean => - AZURE_HD_VOICE_PATTERN.test(voiceName); +// 環境変数の設定ミス(Azure 時代の値の残留など)でも合成を落とさないための +// 最終フォールバック。ここは検証済みの定数なので必ず OpenAI が受理する。 +export const DEFAULT_TTS_VOICE = 'nova'; +export const DEFAULT_TTS_MODEL = 'gpt-4o-mini-tts'; -export const resolveAzureVoiceName = ( +/** + * 使用するボイス名を決める。 + * 優先順位: リクエスト指定 → KV の設定 → 環境変数の既定値。 + * 前二者は妥当なボイス名のときだけ採用する。 + */ +export const resolveOpenAiVoiceName = ( requestedVoiceName: unknown, configuredVoiceName: unknown, defaultVoiceName: string ): string => { const requested = typeof requestedVoiceName === 'string' ? requestedVoiceName.trim() : ''; - if (requested && isAzureVoiceName(requested)) { - return requested; + if (requested && isOpenAiVoiceName(requested)) { + return requested.toLowerCase(); } const configured = typeof configuredVoiceName === 'string' ? configuredVoiceName.trim() : ''; - if (configured && isAzureVoiceName(configured)) { - return configured; + if (configured && isOpenAiVoiceName(configured)) { + return configured.toLowerCase(); + } + + // 環境変数由来の既定値も無検証で通さない。不正なら OpenAI が 400 を返し、 + // /tts 全体が失敗してしまうため、既知のボイスへ倒す。 + const fallback = defaultVoiceName?.trim() ?? ''; + return fallback && isOpenAiVoiceName(fallback) + ? fallback.toLowerCase() + : DEFAULT_TTS_VOICE; +}; + +// 合成に使ってよいモデル。クライアントの指定をそのまま OpenAI へ流すと、 +// 高額なモデルを名指しされて課金が膨らむため許可制にする。 +const TTS_MODELS = new Set(['gpt-4o-mini-tts', 'tts-1', 'tts-1-hd']); + +export const isTtsModel = (model: string): boolean => + TTS_MODELS.has(model.trim().toLowerCase()); + +/** + * 使用するモデルを決める。ボイス名と同じく、リクエスト → KV 設定 → 環境変数の + * 順で、許可済みのモデル名のときだけ採用する。 + */ +export const resolveTtsModel = ( + requestedModel: unknown, + configuredModel: unknown, + defaultModel: string +): string => { + const requested = + typeof requestedModel === 'string' ? requestedModel.trim() : ''; + if (requested && isTtsModel(requested)) { + return requested.toLowerCase(); + } + + const configured = + typeof configuredModel === 'string' ? configuredModel.trim() : ''; + if (configured && isTtsModel(configured)) { + return configured.toLowerCase(); } - return defaultVoiceName; + // ボイス名と同様、環境変数由来の既定値も検証してから採用する + const fallback = defaultModel?.trim() ?? ''; + return fallback && isTtsModel(fallback) + ? fallback.toLowerCase() + : DEFAULT_TTS_MODEL; }; diff --git a/wrangler.jsonc b/wrangler.jsonc index 0eda631..f0c7c90 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -40,12 +40,15 @@ "vars": { "GOOGLE_PLAY_PACKAGE_NAME": "me.tinykitten.trainlcd", - "AZURE_SPEECH_REGION": "southeastasia", - "AZURE_TTS_OUTPUT_FORMAT": "audio-48khz-192kbitrate-mono-mp3", - // HD(DragonHD)ボイスは 非対応のため AZURE_TTS_RATE は設定しない "AI_TRIAGE_MODEL": "@cf/meta/llama-3.1-8b-instruct-fast", - "TTS_JA_VOICE_NAME": "ja-JP-Nanami:DragonHDLatestNeural", - "TTS_EN_VOICE_NAME": "en-US-Jenny:DragonHDOmniLatestNeural", + // --- TTS(/tts)--- + // gpt-4o-mini-tts は SSML 非対応。声色・速度・間の取り方は instructions で指示する。 + // ボイスは多言語対応のため、日英とも同じ女性声にして一人のアナウンサーに揃える。 + "TTS_MODEL": "gpt-4o-mini-tts", + "TTS_JA_VOICE_NAME": "nova", + "TTS_EN_VOICE_NAME": "nova", + "TTS_INSTRUCTIONS_JA": "鉄道の車内自動放送のアナウンサーとして、落ち着いた丁寧な女性の声で読み上げてください。一定の速さを保ち、句読点では短く間を取ります。駅名や路線名は一語ずつ明瞭に発音し、感情を込めすぎず、事務的で聞き取りやすい調子にしてください。", + "TTS_INSTRUCTIONS_EN": "Read this as an automated train announcement in a calm, polite female voice. Keep a steady pace, pause briefly at commas, and pronounce station and line names clearly. Stay neutral and business-like rather than expressive.", "SESSION_TOKEN_TTL_SECONDS": "3600", "UPLOAD_PUBLIC_BASE_URL": "https://uploads-dev.trainlcd.app", "FEW_SHOT_KV_KEY": "config:fewshot", @@ -66,7 +69,7 @@ }, // secrets(`wrangler secret put ` で投入。コミットしない): - // SESSION_JWT_SECRET / AZURE_SPEECH_KEY / GOOGLE_PLAY_SA_KEY / APPSTORE_CONNECT_KEY / + // SESSION_JWT_SECRET / GOOGLE_PLAY_SA_KEY / APPSTORE_CONNECT_KEY / // OCTOKIT_PAT / DISCORD_CS_WEBHOOK_URL / DISCORD_CRASH_WEBHOOK_URL / DISCORD_REVIEW_WEBHOOK_URL / // ANTHROPIC_API_KEY / OPENAI_API_KEY / LANGSMITH_API_KEY @@ -96,12 +99,13 @@ "services": [{ "binding": "SAPI_BFF", "service": "sapi-bff" }], "vars": { "GOOGLE_PLAY_PACKAGE_NAME": "me.tinykitten.trainlcd", - "AZURE_SPEECH_REGION": "southeastasia", - "AZURE_TTS_OUTPUT_FORMAT": "audio-48khz-192kbitrate-mono-mp3", - // HD(DragonHD)ボイスは 非対応のため AZURE_TTS_RATE は設定しない "AI_TRIAGE_MODEL": "@cf/meta/llama-3.1-8b-instruct-fast", - "TTS_JA_VOICE_NAME": "ja-JP-Nanami:DragonHDLatestNeural", - "TTS_EN_VOICE_NAME": "en-US-Jenny:DragonHDLatestNeural", + // --- TTS(/tts)--- + "TTS_MODEL": "gpt-4o-mini-tts", + "TTS_JA_VOICE_NAME": "nova", + "TTS_EN_VOICE_NAME": "nova", + "TTS_INSTRUCTIONS_JA": "鉄道の車内自動放送のアナウンサーとして、落ち着いた丁寧な女性の声で読み上げてください。一定の速さを保ち、句読点では短く間を取ります。駅名や路線名は一語ずつ明瞭に発音し、感情を込めすぎず、事務的で聞き取りやすい調子にしてください。", + "TTS_INSTRUCTIONS_EN": "Read this as an automated train announcement in a calm, polite female voice. Keep a steady pace, pause briefly at commas, and pronounce station and line names clearly. Stay neutral and business-like rather than expressive.", "SESSION_TOKEN_TTL_SECONDS": "3600", "UPLOAD_PUBLIC_BASE_URL": "https://uploads.trainlcd.app", "FEW_SHOT_KV_KEY": "config:fewshot",