diff --git a/src/hooks/useTTS.test.ts b/src/hooks/useTTS.test.ts index 4d8ba205b..a0253d442 100644 --- a/src/hooks/useTTS.test.ts +++ b/src/hooks/useTTS.test.ts @@ -2,38 +2,21 @@ import { renderHook, waitFor } from '@testing-library/react-native'; import { createStore, Provider } from 'jotai'; import React from 'react'; import speechState from '~/store/atoms/speech'; +import { mockFetch } from '~/utils/test/ttsMocks'; import { useTTS } from './useTTS'; jest.mock('~/utils/isDevApp', () => ({ isDevApp: false, })); -const mockFetch = jest.fn(); const mockCreateAudioPlayer = jest.fn(); const mockSetAudioModeAsync = jest.fn(); -jest.mock('expo/fetch', () => ({ - fetch: (...args: unknown[]) => mockFetch(...args), -})); - jest.mock('expo-audio', () => ({ createAudioPlayer: (...args: unknown[]) => mockCreateAudioPlayer(...args), setAudioModeAsync: (...args: unknown[]) => mockSetAudioModeAsync(...args), })); -jest.mock('expo-file-system', () => ({ - Paths: { cache: '/tmp' }, - File: class { - public uri: string; - - constructor(basePath: string, name: string) { - this.uri = `${basePath}/${name}`; - } - - write() {} - }, -})); - jest.mock('./useCurrentLine', () => ({ useCurrentLine: jest.fn(() => undefined), })); @@ -58,50 +41,75 @@ jest.mock('./useCachedAnonymousUser', () => ({ useCachedInitAnonymousUser: jest.fn(() => ({ uid: 'test-user' })), })); -const createMockPlayer = () => { - let playbackStatusListener: - | ((status: { didJustFinish: boolean }) => void) - | null = null; +jest.mock('./useStoppingState', () => ({ + useStoppingState: jest.fn(() => 'CURRENT'), +})); + +type StatusCallback = (status: { + didJustFinish?: boolean; + error?: string; +}) => void; + +const createMockPlayer = (opts?: { autoFinish?: boolean }) => { + let playbackStatusListener: StatusCallback | null = null; + const autoFinish = opts?.autoFinish ?? true; return { - addListener: jest.fn( - ( - _event: string, - callback: (status: { didJustFinish: boolean }) => void - ) => { - playbackStatusListener = callback; - return { remove: jest.fn() }; - } - ), + addListener: jest.fn((_event: string, callback: StatusCallback) => { + playbackStatusListener = callback; + return { remove: jest.fn() }; + }), play: jest.fn(() => { - setTimeout(() => { - playbackStatusListener?.({ didJustFinish: true }); - }, 0); + if (autoFinish) { + setTimeout(() => { + playbackStatusListener?.({ didJustFinish: true }); + }, 0); + } }), pause: jest.fn(), remove: jest.fn(), + emitStatus: (status: { didJustFinish?: boolean; error?: string }) => { + playbackStatusListener?.(status); + }, }; }; +const defaultSpeechState = { + enabled: true, + backgroundEnabled: false, + ttsEnabledLanguages: ['JA', 'EN'] as ('JA' | 'EN')[], + monetizedPlanEnabled: false, +}; + +const createWrapper = + (store: ReturnType) => + ({ children }: { children: React.ReactNode }) => + React.createElement(Provider, { store }, children); + +const mockSuccessfulFetch = () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + id: 'tts-id', + jaAudioContent: 'QQ==', + enAudioContent: 'QQ==', + }, + }), + }); +}; + describe('useTTS', () => { beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks(); - - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - result: { - id: 'tts-id', - jaAudioContent: 'QQ==', - enAudioContent: 'QQ==', - }, - }), - }); - - mockCreateAudioPlayer.mockImplementation((_source: { uri: string }) => - createMockPlayer() - ); + mockSuccessfulFetch(); + mockCreateAudioPlayer.mockImplementation(() => createMockPlayer()); + // テスト間で useTTSText の mock を復元 + const { useTTSText } = jest.requireMock('./useTTSText') as { + useTTSText: jest.Mock; + }; + useTTSText.mockReturnValue(['ja text', 'en text']); }); afterEach(() => { @@ -113,16 +121,11 @@ describe('useTTS', () => { it('英語のみ有効時は英語音声のみ再生プレイヤーを生成する', async () => { const store = createStore(); store.set(speechState, { - enabled: true, - backgroundEnabled: false, + ...defaultSpeechState, ttsEnabledLanguages: ['EN'], - monetizedPlanEnabled: false, }); - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(Provider, { store }, children); - - renderHook(() => useTTS(), { wrapper }); + renderHook(() => useTTS(), { wrapper: createWrapper(store) }); await waitFor(() => { expect(mockFetch).toHaveBeenCalled(); @@ -138,4 +141,198 @@ describe('useTTS', () => { uri: '/tmp/tts-id_en.mp3', }); }); + + it('JA+EN有効時はJA→ENの順に再生する', async () => { + const store = createStore(); + store.set(speechState, defaultSpeechState); + + const calls: string[] = []; + mockCreateAudioPlayer.mockImplementation((source: { uri: string }) => { + calls.push(source.uri); + return createMockPlayer(); + }); + + renderHook(() => useTTS(), { wrapper: createWrapper(store) }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + // JA プレイヤーが先に生成される + jest.runAllTimers(); + + await waitFor(() => { + expect(calls.length).toBeGreaterThanOrEqual(1); + }); + + expect(calls[0]).toBe('/tmp/tts-id_ja.mp3'); + + // EN_PLAYBACK_DELAY_MS 後に EN プレイヤーが生成される + jest.runAllTimers(); + + await waitFor(() => { + expect(calls.length).toBe(2); + }); + + expect(calls[1]).toBe('/tmp/tts-id_en.mp3'); + }); + + it('JAのみ有効時はJAプレイヤーのみ生成する', async () => { + const store = createStore(); + store.set(speechState, { + ...defaultSpeechState, + ttsEnabledLanguages: ['JA'], + }); + + renderHook(() => useTTS(), { wrapper: createWrapper(store) }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + jest.runAllTimers(); + + await waitFor(() => { + expect(mockCreateAudioPlayer).toHaveBeenCalledTimes(1); + }); + + expect(mockCreateAudioPlayer).toHaveBeenCalledWith({ + uri: '/tmp/tts-id_ja.mp3', + }); + }); + + it('無効時はfetch/playしない', async () => { + const store = createStore(); + store.set(speechState, { + ...defaultSpeechState, + enabled: false, + }); + + renderHook(() => useTTS(), { wrapper: createWrapper(store) }); + + jest.runAllTimers(); + + await waitFor(() => { + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockCreateAudioPlayer).not.toHaveBeenCalled(); + }); + }); + + it('テキスト空時にpendingをクリアする', async () => { + const { useTTSText } = jest.requireMock('./useTTSText') as { + useTTSText: jest.Mock; + }; + + const store = createStore(); + store.set(speechState, defaultSpeechState); + + // 最初は有効なテキストで再生開始 + useTTSText.mockReturnValue(['ja text', 'en text']); + + const { rerender } = renderHook(() => useTTS(), { + wrapper: createWrapper(store), + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + // テキストを空にして再描画 + useTTSText.mockReturnValue(['', '']); + rerender({}); + + jest.runAllTimers(); + + // 空テキストではfetchが追加で呼ばれない + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('APIエラー時にfinishPlayingが呼ばれる', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); + + const store = createStore(); + store.set(speechState, defaultSpeechState); + + renderHook(() => useTTS(), { wrapper: createWrapper(store) }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + jest.runAllTimers(); + + // APIエラー後、プレイヤーは生成されない + expect(mockCreateAudioPlayer).not.toHaveBeenCalled(); + }); + + it('タイムアウト後に強制リセットされる', async () => { + const store = createStore(); + store.set(speechState, { + ...defaultSpeechState, + ttsEnabledLanguages: ['EN'], + }); + + // didJustFinish を発火しないプレイヤー + mockCreateAudioPlayer.mockImplementation(() => + createMockPlayer({ autoFinish: false }) + ); + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + renderHook(() => useTTS(), { wrapper: createWrapper(store) }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + // プレイヤーが生成されるまで待つ + jest.advanceTimersByTime(100); + + await waitFor(() => { + expect(mockCreateAudioPlayer).toHaveBeenCalledTimes(1); + }); + + // 60秒のタイムアウトを発火 + jest.advanceTimersByTime(60_000); + + expect(warnSpy).toHaveBeenCalledWith( + '[useTTS] Playback safety timeout reached, force resetting' + ); + + warnSpy.mockRestore(); + }); + + it('アンマウント時にクリーンアップされる', async () => { + const store = createStore(); + store.set(speechState, { + ...defaultSpeechState, + ttsEnabledLanguages: ['EN'], + }); + + const mockPlayer = createMockPlayer({ autoFinish: false }); + mockCreateAudioPlayer.mockReturnValue(mockPlayer); + + const { unmount } = renderHook(() => useTTS(), { + wrapper: createWrapper(store), + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + jest.advanceTimersByTime(100); + + await waitFor(() => { + expect(mockCreateAudioPlayer).toHaveBeenCalled(); + }); + + unmount(); + + expect(mockPlayer.pause).toHaveBeenCalled(); + expect(mockPlayer.remove).toHaveBeenCalled(); + }); }); diff --git a/src/hooks/useTTS.ts b/src/hooks/useTTS.ts index b65fdb5ea..af3b9b990 100644 --- a/src/hooks/useTTS.ts +++ b/src/hooks/useTTS.ts @@ -1,8 +1,5 @@ import { getIdToken } from '@react-native-firebase/auth'; -import { fetch } from 'expo/fetch'; -import type { AudioPlayer } from 'expo-audio'; -import { createAudioPlayer, setAudioModeAsync } from 'expo-audio'; -import { File, Paths } from 'expo-file-system'; +import { setAudioModeAsync } from 'expo-audio'; import { useAtomValue } from 'jotai'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import { DEV_TTS_API_URL, PRODUCTION_TTS_API_URL } from 'react-native-dotenv'; @@ -11,6 +8,13 @@ import speechState from '../store/atoms/speech'; import stationState from '../store/atoms/station'; import { computeSuppressionDecision } from '../utils/computeSuppressionDecision'; import { isDevApp } from '../utils/isDevApp'; +import { + type PlayAudioHandle, + playAudio, + safeRemoveListener, + safeRemovePlayer, +} from '../utils/ttsAudioPlayer'; +import { fetchSpeechAudio } from '../utils/ttsSpeechFetcher'; import { useBusTTSText } from './useBusTTSText'; import { useCachedInitAnonymousUser } from './useCachedAnonymousUser'; import { useCurrentLine } from './useCurrentLine'; @@ -18,47 +22,6 @@ import { usePrevious } from './usePrevious'; import { useStoppingState } from './useStoppingState'; import { useTTSText } from './useTTSText'; -const BASE64_ALPHABET = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; - -const base64ToUint8Array = (input: string): Uint8Array => { - const sanitized = input.replace(/[^A-Za-z0-9+/=]/g, ''); - const length = - (sanitized.length * 3) / 4 - - (sanitized.endsWith('==') ? 2 : sanitized.endsWith('=') ? 1 : 0); - const bytes = new Uint8Array(length); - - let byteIndex = 0; - const decodeChar = (char: string): number => { - if (char === '=') { - return 0; - } - const index = BASE64_ALPHABET.indexOf(char); - if (index === -1) { - throw new Error('Invalid base64 character.'); - } - return index; - }; - - for (let i = 0; i < sanitized.length; i += 4) { - const chunk = - (decodeChar(sanitized[i]) << 18) | - (decodeChar(sanitized[i + 1]) << 12) | - (decodeChar(sanitized[i + 2]) << 6) | - decodeChar(sanitized[i + 3]); - - bytes[byteIndex++] = (chunk >> 16) & 0xff; - if (sanitized[i + 2] !== '=') { - bytes[byteIndex++] = (chunk >> 8) & 0xff; - } - if (sanitized[i + 3] !== '=') { - bytes[byteIndex++] = chunk & 0xff; - } - } - - return bytes; -}; - // 再生が完了しない場合のフォールバックタイムアウト(ミリ秒) const PLAYBACK_TIMEOUT_MS = 60_000; @@ -99,11 +62,18 @@ export const useTTS = (): void => { const user = useCachedInitAnonymousUser(); - const soundJaRef = useRef(null); - const soundEnRef = useRef(null); + const jaHandleRef = useRef(null); + const enHandleRef = useRef(null); const playingTimeoutRef = useRef | null>(null); - const jaListenerRef = useRef<{ remove: () => void } | null>(null); - const enListenerRef = useRef<{ remove: () => void } | null>(null); + + const cleanupAllPlayers = useCallback(() => { + safeRemoveListener(jaHandleRef.current?.listener ?? null); + safeRemoveListener(enHandleRef.current?.listener ?? null); + safeRemovePlayer(jaHandleRef.current?.player ?? null); + safeRemovePlayer(enHandleRef.current?.player ?? null); + jaHandleRef.current = null; + enHandleRef.current = null; + }, []); useEffect(() => { (async () => { @@ -152,107 +122,14 @@ export const useTTS = (): void => { firstSpeechRef.current = false; suppressPostFirstSpeechRef.current = true; - // 既存のリスナーとプレイヤーをクリーンアップ - try { - jaListenerRef.current?.remove(); - } catch {} - jaListenerRef.current = null; - try { - enListenerRef.current?.remove(); - } catch {} - enListenerRef.current = null; - try { - soundJaRef.current?.pause(); - soundJaRef.current?.remove(); - } catch {} - try { - soundEnRef.current?.pause(); - soundEnRef.current?.remove(); - } catch {} - soundJaRef.current = null; - soundEnRef.current = null; + cleanupAllPlayers(); - // 既存のタイムアウトをクリア if (playingTimeoutRef.current) { clearTimeout(playingTimeoutRef.current); } - if (!playJapanese && playEnglish) { - const soundEn = createAudioPlayer({ - uri: pathEn, - }); - soundEnRef.current = soundEn; - playingRef.current = true; - - playingTimeoutRef.current = setTimeout(() => { - if (!playingRef.current) { - return; - } - console.warn( - '[useTTS] Playback safety timeout reached, force resetting' - ); - try { - enListenerRef.current?.remove(); - } catch {} - enListenerRef.current = null; - try { - soundEnRef.current?.pause(); - soundEnRef.current?.remove(); - } catch {} - soundEnRef.current = null; - finishPlaying(); - }, PLAYBACK_TIMEOUT_MS); - - const enRemoveListener = soundEn.addListener( - 'playbackStatusUpdate', - (enStatus) => { - if (enStatus.didJustFinish) { - enRemoveListener?.remove(); - enListenerRef.current = null; - try { - soundEn.remove(); - } catch (e) { - console.warn('[useTTS] Failed to remove soundEn:', e); - } - soundEnRef.current = null; - finishPlaying(); - } else if ('error' in enStatus && enStatus.error) { - console.warn('[useTTS] soundEn error:', enStatus.error); - enRemoveListener?.remove(); - enListenerRef.current = null; - try { - soundEn.remove(); - } catch {} - soundEnRef.current = null; - finishPlaying(); - } - } - ); - enListenerRef.current = enRemoveListener; - - try { - soundEn.play(); - } catch (e) { - console.error('[useTTS] Failed to play soundEn:', e); - enRemoveListener?.remove(); - enListenerRef.current = null; - try { - soundEn.remove(); - } catch {} - soundEnRef.current = null; - finishPlaying(); - } - return; - } - - const soundJa = createAudioPlayer({ - uri: pathJa, - }); - - soundJaRef.current = soundJa; playingRef.current = true; - // 再生が完了しない場合のフォールバックタイムアウト playingTimeoutRef.current = setTimeout(() => { if (!playingRef.current) { return; @@ -260,232 +137,115 @@ export const useTTS = (): void => { console.warn( '[useTTS] Playback safety timeout reached, force resetting' ); - try { - jaListenerRef.current?.remove(); - } catch {} - jaListenerRef.current = null; - try { - enListenerRef.current?.remove(); - } catch {} - enListenerRef.current = null; - try { - soundJaRef.current?.pause(); - soundJaRef.current?.remove(); - } catch {} - try { - soundEnRef.current?.pause(); - soundEnRef.current?.remove(); - } catch {} - soundJaRef.current = null; - soundEnRef.current = null; + cleanupAllPlayers(); finishPlaying(); }, PLAYBACK_TIMEOUT_MS); - const jaRemoveListener = soundJa.addListener( - 'playbackStatusUpdate', - (jaStatus) => { - if (jaStatus.didJustFinish) { - jaRemoveListener?.remove(); - jaListenerRef.current = null; - // 日本語プレイヤーはまだremoveしない - // コールバック内でremoveするとオーディオセッションが不安定になり - // 直後に生成する英語プレイヤーの再生が開始されないことがある - const removeSoundJa = () => { - try { - soundJa.remove(); - } catch {} - if (soundJaRef.current === soundJa) { - soundJaRef.current = null; + if (!playJapanese && playEnglish) { + const enCleanup = () => { + safeRemovePlayer(enHandleRef.current?.player ?? null); + enHandleRef.current = null; + finishPlaying(); + }; + + enHandleRef.current = playAudio({ + uri: pathEn, + onFinish: enCleanup, + onError: () => enCleanup(), + }); + return; + } + + // JA(+ 任意で EN)再生 + const removeSoundJa = () => { + const handle = jaHandleRef.current; + if (handle) { + safeRemovePlayer(handle.player); + jaHandleRef.current = null; + } + }; + + jaHandleRef.current = playAudio({ + uri: pathJa, + onFinish: () => { + // 日本語プレイヤーはまだremoveしない + // コールバック内でremoveするとオーディオセッションが不安定になり + // 直後に生成する英語プレイヤーの再生が開始されないことがある + if (isLoadableRef.current && playEnglish) { + // 音声セッションが安定するまで短いディレイを入れてから英語を再生 + setTimeout(() => { + if (!isLoadableRef.current) { + removeSoundJa(); + finishPlaying(); + return; } - }; - if (isLoadableRef.current && playEnglish) { - // 音声セッションが安定するまで短いディレイを入れてから英語を再生 - setTimeout(() => { - if (!isLoadableRef.current) { - removeSoundJa(); - finishPlaying(); - return; - } - - // 日本語再生完了後に英語プレイヤーを生成して再生(リソース節約) - const soundEn = createAudioPlayer({ - uri: pathEn, - }); - soundEnRef.current = soundEn; - - const enRemoveListener = soundEn.addListener( - 'playbackStatusUpdate', - (enStatus) => { - if (enStatus.didJustFinish) { - enRemoveListener?.remove(); - enListenerRef.current = null; - try { - soundEn.remove(); - } catch (e) { - console.warn('[useTTS] Failed to remove soundEn:', e); - } - soundEnRef.current = null; - removeSoundJa(); - finishPlaying(); - } else if ('error' in enStatus && enStatus.error) { - console.warn('[useTTS] soundEn error:', enStatus.error); - enRemoveListener?.remove(); - enListenerRef.current = null; - try { - soundEn.remove(); - } catch {} - soundEnRef.current = null; - removeSoundJa(); - finishPlaying(); - } - } - ); - enListenerRef.current = enRemoveListener; - - try { - soundEn.play(); - } catch (e) { - console.error('[useTTS] Failed to play soundEn:', e); - enRemoveListener?.remove(); - enListenerRef.current = null; - try { - soundEn.remove(); - } catch {} - soundEnRef.current = null; - removeSoundJa(); - finishPlaying(); - } - }, EN_PLAYBACK_DELAY_MS); - } else { - // 既にアンマウント等で再生不可なら英語を鳴らさず完全停止 - removeSoundJa(); - finishPlaying(); - } - } else if ('error' in jaStatus && jaStatus.error) { - console.warn('[useTTS] soundJa error:', jaStatus.error); - jaRemoveListener?.remove(); - jaListenerRef.current = null; - try { - soundJa.remove(); - } catch {} - soundJaRef.current = null; + + const enCleanup = () => { + safeRemovePlayer(enHandleRef.current?.player ?? null); + enHandleRef.current = null; + removeSoundJa(); + finishPlaying(); + }; + + enHandleRef.current = playAudio({ + uri: pathEn, + onFinish: enCleanup, + onError: () => enCleanup(), + }); + }, EN_PLAYBACK_DELAY_MS); + } else { + removeSoundJa(); finishPlaying(); } - } - ); - jaListenerRef.current = jaRemoveListener; - - try { - soundJa.play(); - } catch (e) { - console.error('[useTTS] Failed to play soundJa:', e); - jaRemoveListener?.remove(); - jaListenerRef.current = null; - try { - soundJa.remove(); - } catch {} - soundJaRef.current = null; - finishPlaying(); - } + }, + onError: () => { + removeSoundJa(); + finishPlaying(); + }, + }); }, - [finishPlaying, shouldSpeakEnglish, shouldSpeakJapanese] + [cleanupAllPlayers, finishPlaying, shouldSpeakEnglish, shouldSpeakJapanese] ); const ttsApiUrl = useMemo(() => { return isDevApp ? DEV_TTS_API_URL : PRODUCTION_TTS_API_URL; }, []); - const fetchSpeechWithText = useCallback( + const speechWithText = useCallback( async (ja: string, en: string) => { if (!ja.length || !en.length || !isLoadableRef.current) { return; } - const reqBody = { - data: { - ssmlJa: `${ja.trim()}`, - ssmlEn: `${en.trim()}`, - }, - }; - + playingRef.current = true; try { const idToken = user && (await getIdToken(user)); if (!idToken) { console.warn('[useTTS] idToken is missing, skipping fetch'); + finishPlaying(); return; } - const response = await fetch(ttsApiUrl, { - headers: { - 'content-type': 'application/json; charset=UTF-8', - Authorization: `Bearer ${idToken}`, - }, - body: JSON.stringify(reqBody), - method: 'POST', + const fetched = await fetchSpeechAudio({ + textJa: ja, + textEn: en, + apiUrl: ttsApiUrl, + idToken, }); - if (!response.ok) { - console.warn( - `[useTTS] TTS API returned ${response.status}: ${response.statusText}` - ); - return; - } - - const ttsJson = await response.json(); - - if (!ttsJson?.result?.id) { - console.warn('[useTTS] Invalid TTS response: missing result.id'); - return; - } - - const { jaAudioContent, enAudioContent, id } = ttsJson.result; - - if (!jaAudioContent || !enAudioContent) { - console.warn( - '[useTTS] Missing audio content in TTS response, skipping file write' - ); - return; - } - - const fileJa = new File(Paths.cache, `${id}_ja.mp3`); - const fileEn = new File(Paths.cache, `${id}_en.mp3`); - - fileJa.write(base64ToUint8Array(jaAudioContent)); - fileEn.write(base64ToUint8Array(enAudioContent)); - - return { - id, - pathJa: fileJa.uri, - pathEn: fileEn.uri, - }; - } catch (error) { - console.error('[useTTS] fetchSpeech error:', error); - return; - } - }, - [ttsApiUrl, user] - ); - - const speechWithText = useCallback( - async (ja: string, en: string) => { - playingRef.current = true; - try { - const fetched = await fetchSpeechWithText(ja, en); if (!fetched) { console.warn('[useTTS] Failed to fetch speech audio'); finishPlaying(); return; } - const { pathJa, pathEn } = fetched; - - await speakFromPath(pathJa, pathEn); + await speakFromPath(fetched.pathJa, fetched.pathEn); } catch (error) { console.error('[useTTS] speech error:', error); finishPlaying(); } }, - [fetchSpeechWithText, finishPlaying, speakFromPath] + [finishPlaying, speakFromPath, ttsApiUrl, user] ); speechWithTextRef.current = speechWithText; @@ -560,29 +320,8 @@ export const useTTS = (): void => { clearTimeout(playingTimeoutRef.current); playingTimeoutRef.current = null; } - try { - jaListenerRef.current?.remove(); - } catch {} - jaListenerRef.current = null; - try { - enListenerRef.current?.remove(); - } catch {} - enListenerRef.current = null; - try { - soundJaRef.current?.pause(); - } catch {} - try { - soundEnRef.current?.pause(); - } catch {} - try { - soundJaRef.current?.remove(); - } catch {} - try { - soundEnRef.current?.remove(); - } catch {} - soundJaRef.current = null; - soundEnRef.current = null; + cleanupAllPlayers(); playingRef.current = false; }; - }, []); + }, [cleanupAllPlayers]); }; diff --git a/src/utils/base64ToUint8Array.test.ts b/src/utils/base64ToUint8Array.test.ts new file mode 100644 index 000000000..a69b0a3ef --- /dev/null +++ b/src/utils/base64ToUint8Array.test.ts @@ -0,0 +1,47 @@ +import { base64ToUint8Array } from './base64ToUint8Array'; + +describe('base64ToUint8Array', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('既知文字列をデコードできる', () => { + // "Hello" = SGVsbG8= + const result = base64ToUint8Array('SGVsbG8='); + expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111])); + }); + + it('パディングなしの文字列をデコードできる', () => { + // "abc" = YWJj (no padding) + const result = base64ToUint8Array('YWJj'); + expect(result).toEqual(new Uint8Array([97, 98, 99])); + }); + + it('パディング1つの文字列をデコードできる', () => { + // "ab" = YWI= + const result = base64ToUint8Array('YWI='); + expect(result).toEqual(new Uint8Array([97, 98])); + }); + + it('パディング2つの文字列をデコードできる', () => { + // "a" = YQ== + const result = base64ToUint8Array('YQ=='); + expect(result).toEqual(new Uint8Array([97])); + }); + + it('空文字列を渡すと空のUint8Arrayを返す', () => { + const result = base64ToUint8Array(''); + expect(result).toEqual(new Uint8Array(0)); + }); + + it('改行やスペースを含むbase64をデコードできる', () => { + const result = base64ToUint8Array('SGVs\nbG8='); + expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111])); + }); + + it('単一バイト QQ== をデコードできる', () => { + // "A" = QQ== + const result = base64ToUint8Array('QQ=='); + expect(result).toEqual(new Uint8Array([65])); + }); +}); diff --git a/src/utils/base64ToUint8Array.ts b/src/utils/base64ToUint8Array.ts new file mode 100644 index 000000000..e9085cbfc --- /dev/null +++ b/src/utils/base64ToUint8Array.ts @@ -0,0 +1,40 @@ +const BASE64_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + +export const base64ToUint8Array = (input: string): Uint8Array => { + const sanitized = input.replace(/[^A-Za-z0-9+/=]/g, ''); + const length = + (sanitized.length * 3) / 4 - + (sanitized.endsWith('==') ? 2 : sanitized.endsWith('=') ? 1 : 0); + const bytes = new Uint8Array(length); + + let byteIndex = 0; + const decodeChar = (char: string): number => { + if (char === '=') { + return 0; + } + const index = BASE64_ALPHABET.indexOf(char); + if (index === -1) { + throw new Error('Invalid base64 character.'); + } + return index; + }; + + for (let i = 0; i < sanitized.length; i += 4) { + const chunk = + (decodeChar(sanitized[i]) << 18) | + (decodeChar(sanitized[i + 1]) << 12) | + (decodeChar(sanitized[i + 2]) << 6) | + decodeChar(sanitized[i + 3]); + + bytes[byteIndex++] = (chunk >> 16) & 0xff; + if (sanitized[i + 2] !== '=') { + bytes[byteIndex++] = (chunk >> 8) & 0xff; + } + if (sanitized[i + 3] !== '=') { + bytes[byteIndex++] = chunk & 0xff; + } + } + + return bytes; +}; diff --git a/src/utils/test/ttsMocks.ts b/src/utils/test/ttsMocks.ts new file mode 100644 index 000000000..c4f1ea381 --- /dev/null +++ b/src/utils/test/ttsMocks.ts @@ -0,0 +1,18 @@ +export const mockFetch = jest.fn(); + +jest.mock('expo/fetch', () => ({ + fetch: (...args: unknown[]) => mockFetch(...args), +})); + +jest.mock('expo-file-system', () => ({ + Paths: { cache: '/tmp' }, + File: class { + public uri: string; + + constructor(basePath: string, name: string) { + this.uri = `${basePath}/${name}`; + } + + write() {} + }, +})); diff --git a/src/utils/ttsAudioPlayer.test.ts b/src/utils/ttsAudioPlayer.test.ts new file mode 100644 index 000000000..850c3b7dd --- /dev/null +++ b/src/utils/ttsAudioPlayer.test.ts @@ -0,0 +1,172 @@ +import { + playAudio, + safeRemoveListener, + safeRemovePlayer, +} from './ttsAudioPlayer'; + +const mockCreateAudioPlayer = jest.fn(); + +jest.mock('expo-audio', () => ({ + createAudioPlayer: (...args: unknown[]) => mockCreateAudioPlayer(...args), +})); + +type StatusCallback = (status: { + didJustFinish?: boolean; + error?: string; +}) => void; + +const createMockPlayer = () => { + let statusCallback: StatusCallback | null = null; + const listenerRemove = jest.fn(); + return { + player: { + addListener: jest.fn((_event: string, callback: StatusCallback) => { + statusCallback = callback; + return { remove: listenerRemove }; + }), + play: jest.fn(), + pause: jest.fn(), + remove: jest.fn(), + }, + listenerRemove, + emitStatus: (status: { didJustFinish?: boolean; error?: string }) => { + statusCallback?.(status); + }, + }; +}; + +describe('safeRemoveListener', () => { + it('null を渡しても例外にならない', () => { + expect(() => safeRemoveListener(null)).not.toThrow(); + }); + + it('listener の remove を呼ぶ', () => { + const remove = jest.fn(); + safeRemoveListener({ remove }); + expect(remove).toHaveBeenCalledTimes(1); + }); + + it('remove が例外を投げても安全', () => { + const remove = jest.fn(() => { + throw new Error('already removed'); + }); + expect(() => safeRemoveListener({ remove })).not.toThrow(); + }); +}); + +describe('safeRemovePlayer', () => { + it('null を渡しても例外にならない', () => { + expect(() => safeRemovePlayer(null)).not.toThrow(); + }); + + it('player の pause と remove を呼ぶ', () => { + const player = { + pause: jest.fn(), + remove: jest.fn(), + } as unknown as Parameters[0]; + safeRemovePlayer(player); + expect( + (player as unknown as { pause: jest.Mock }).pause + ).toHaveBeenCalledTimes(1); + expect( + (player as unknown as { remove: jest.Mock }).remove + ).toHaveBeenCalledTimes(1); + }); + + it('pause/remove が例外を投げても安全', () => { + const player = { + pause: jest.fn(() => { + throw new Error('fail'); + }), + remove: jest.fn(), + } as unknown as Parameters[0]; + expect(() => safeRemovePlayer(player)).not.toThrow(); + }); +}); + +describe('playAudio', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + it('再生完了時に onFinish を呼ぶ', () => { + const mock = createMockPlayer(); + mockCreateAudioPlayer.mockReturnValue(mock.player); + + const onFinish = jest.fn(); + const onError = jest.fn(); + playAudio({ uri: 'test.mp3', onFinish, onError }); + + expect(mock.player.play).toHaveBeenCalledTimes(1); + + mock.emitStatus({ didJustFinish: true }); + + expect(onFinish).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + expect(mock.listenerRemove).toHaveBeenCalledTimes(1); + }); + + it('再生エラー時に onError を呼ぶ', () => { + const mock = createMockPlayer(); + mockCreateAudioPlayer.mockReturnValue(mock.player); + + const onFinish = jest.fn(); + const onError = jest.fn(); + playAudio({ uri: 'test.mp3', onFinish, onError }); + + mock.emitStatus({ error: 'decode error' }); + + expect(onError).toHaveBeenCalledWith('decode error'); + expect(onFinish).not.toHaveBeenCalled(); + expect(mock.listenerRemove).toHaveBeenCalledTimes(1); + }); + + it('play() が例外を投げた場合に onError を呼ぶ', () => { + const mock = createMockPlayer(); + const playError = new Error('play failed'); + mock.player.play.mockImplementation(() => { + throw playError; + }); + mockCreateAudioPlayer.mockReturnValue(mock.player); + + const onFinish = jest.fn(); + const onError = jest.fn(); + playAudio({ uri: 'test.mp3', onFinish, onError }); + + expect(onError).toHaveBeenCalledWith(playError); + expect(onFinish).not.toHaveBeenCalled(); + expect(mock.listenerRemove).toHaveBeenCalledTimes(1); + }); + + it('PlayAudioHandle の player と listener を返す', () => { + const mock = createMockPlayer(); + mockCreateAudioPlayer.mockReturnValue(mock.player); + + const handle = playAudio({ + uri: 'test.mp3', + onFinish: jest.fn(), + onError: jest.fn(), + }); + + expect(handle.player).toBe(mock.player); + expect(handle.listener).toEqual({ remove: expect.any(Function) }); + }); + + it('指定した uri でプレイヤーを作成する', () => { + const mock = createMockPlayer(); + mockCreateAudioPlayer.mockReturnValue(mock.player); + + playAudio({ + uri: '/path/to/audio.mp3', + onFinish: jest.fn(), + onError: jest.fn(), + }); + + expect(mockCreateAudioPlayer).toHaveBeenCalledWith({ + uri: '/path/to/audio.mp3', + }); + }); +}); diff --git a/src/utils/ttsAudioPlayer.ts b/src/utils/ttsAudioPlayer.ts new file mode 100644 index 000000000..b9ed55413 --- /dev/null +++ b/src/utils/ttsAudioPlayer.ts @@ -0,0 +1,51 @@ +import type { AudioPlayer } from 'expo-audio'; +import { createAudioPlayer } from 'expo-audio'; + +export const safeRemoveListener = ( + listener: { remove: () => void } | null +): void => { + try { + listener?.remove(); + } catch {} +}; + +export const safeRemovePlayer = (player: AudioPlayer | null): void => { + try { + player?.pause(); + player?.remove(); + } catch {} +}; + +export interface PlayAudioHandle { + player: AudioPlayer; + listener: { remove: () => void }; +} + +export const playAudio = (options: { + uri: string; + onFinish: () => void; + onError: (error: unknown) => void; +}): PlayAudioHandle => { + const { uri, onFinish, onError } = options; + const player = createAudioPlayer({ uri }); + + const listener = player.addListener('playbackStatusUpdate', (status) => { + if (status.didJustFinish) { + safeRemoveListener(listener); + onFinish(); + } else if ('error' in status && status.error) { + console.warn('[ttsAudioPlayer] playback error:', status.error); + safeRemoveListener(listener); + onError(status.error); + } + }); + + try { + player.play(); + } catch (e) { + safeRemoveListener(listener); + onError(e); + } + + return { player, listener }; +}; diff --git a/src/utils/ttsSpeechFetcher.test.ts b/src/utils/ttsSpeechFetcher.test.ts new file mode 100644 index 000000000..d4cd225d1 --- /dev/null +++ b/src/utils/ttsSpeechFetcher.test.ts @@ -0,0 +1,128 @@ +import { mockFetch } from '~/utils/test/ttsMocks'; +import { fetchSpeechAudio } from './ttsSpeechFetcher'; + +const defaultOptions = { + textJa: 'こんにちは', + textEn: 'Hello', + apiUrl: 'https://api.example.com/tts', + idToken: 'test-token', +}; + +describe('fetchSpeechAudio', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + it('成功レスポンスでファイルパスを返す', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + id: 'tts-123', + jaAudioContent: 'QQ==', + enAudioContent: 'QQ==', + }, + }), + }); + + const result = await fetchSpeechAudio(defaultOptions); + + expect(result).toEqual({ + id: 'tts-123', + pathJa: '/tmp/tts-123_ja.mp3', + pathEn: '/tmp/tts-123_en.mp3', + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/tts', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + }), + }) + ); + }); + + it('textJa が空の場合は null を返す', async () => { + const result = await fetchSpeechAudio({ ...defaultOptions, textJa: '' }); + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('textEn が空の場合は null を返す', async () => { + const result = await fetchSpeechAudio({ ...defaultOptions, textEn: '' }); + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('HTTP エラー時に null を返す', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); + + const result = await fetchSpeechAudio(defaultOptions); + expect(result).toBeNull(); + }); + + it('レスポンスに result.id がない場合は null を返す', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ result: {} }), + }); + + const result = await fetchSpeechAudio(defaultOptions); + expect(result).toBeNull(); + }); + + it('オーディオコンテンツが欠けている場合は null を返す', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + id: 'tts-123', + jaAudioContent: 'QQ==', + enAudioContent: null, + }, + }), + }); + + const result = await fetchSpeechAudio(defaultOptions); + expect(result).toBeNull(); + }); + + it('ネットワーク例外時に null を返す', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const result = await fetchSpeechAudio(defaultOptions); + expect(result).toBeNull(); + }); + + it('SSML でテキストをラップしてリクエストする', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + id: 'tts-123', + jaAudioContent: 'QQ==', + enAudioContent: 'QQ==', + }, + }), + }); + + await fetchSpeechAudio({ + ...defaultOptions, + textJa: ' テスト ', + textEn: ' test ', + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.data.ssmlJa).toBe('テスト'); + expect(body.data.ssmlEn).toBe('test'); + }); +}); diff --git a/src/utils/ttsSpeechFetcher.ts b/src/utils/ttsSpeechFetcher.ts new file mode 100644 index 000000000..3cf43405a --- /dev/null +++ b/src/utils/ttsSpeechFetcher.ts @@ -0,0 +1,78 @@ +import { fetch } from 'expo/fetch'; +import { File, Paths } from 'expo-file-system'; +import { base64ToUint8Array } from './base64ToUint8Array'; + +export interface FetchSpeechOptions { + textJa: string; + textEn: string; + apiUrl: string; + idToken: string; +} + +export const fetchSpeechAudio = async ( + options: FetchSpeechOptions +): Promise<{ id: string; pathJa: string; pathEn: string } | null> => { + const { textJa, textEn, apiUrl, idToken } = options; + + if (!textJa.length || !textEn.length) { + return null; + } + + const reqBody = { + data: { + ssmlJa: `${textJa.trim()}`, + ssmlEn: `${textEn.trim()}`, + }, + }; + + try { + const response = await fetch(apiUrl, { + headers: { + 'content-type': 'application/json; charset=UTF-8', + Authorization: `Bearer ${idToken}`, + }, + body: JSON.stringify(reqBody), + method: 'POST', + }); + + if (!response.ok) { + console.warn( + `[ttsSpeechFetcher] TTS API returned ${response.status}: ${response.statusText}` + ); + return null; + } + + const ttsJson = await response.json(); + + if (!ttsJson?.result?.id) { + console.warn( + '[ttsSpeechFetcher] Invalid TTS response: missing result.id' + ); + return null; + } + + const { jaAudioContent, enAudioContent, id } = ttsJson.result; + + if (!jaAudioContent || !enAudioContent) { + console.warn( + '[ttsSpeechFetcher] Missing audio content in TTS response, skipping file write' + ); + return null; + } + + const fileJa = new File(Paths.cache, `${id}_ja.mp3`); + const fileEn = new File(Paths.cache, `${id}_en.mp3`); + + fileJa.write(base64ToUint8Array(jaAudioContent)); + fileEn.write(base64ToUint8Array(enAudioContent)); + + return { + id, + pathJa: fileJa.uri, + pathEn: fileEn.uri, + }; + } catch (error) { + console.error('[ttsSpeechFetcher] fetchSpeech error:', error); + return null; + } +};