From 89833d596b0605cbd72bb17c347facb882f148bc Mon Sep 17 00:00:00 2001 From: Victor Sonck Date: Sun, 22 Jun 2025 17:08:03 +0200 Subject: [PATCH] Allow for player speed changes as well as native voice speed changes. Kokoro for example swallows parts of words when asked to go faster than maybe 1.3x. This fixes that, by simply speeding up the player instead. Since we're already using the html5 version of howler, by default it will pitch-correct the audio and it sounds quite good! --- src/components/player/SpeedControl.tsx | 114 ++++++++++++++++++------- src/components/player/TTSPlayer.tsx | 6 +- src/contexts/ConfigContext.tsx | 17 +++- src/contexts/TTSContext.tsx | 40 ++++++++- 4 files changed, 141 insertions(+), 36 deletions(-) diff --git a/src/components/player/SpeedControl.tsx b/src/components/player/SpeedControl.tsx index af9e2e6..0cd0aa1 100644 --- a/src/components/player/SpeedControl.tsx +++ b/src/components/player/SpeedControl.tsx @@ -5,56 +5,106 @@ import { ChevronUpDownIcon } from '@/components/icons/Icons'; import { useConfig } from '@/contexts/ConfigContext'; import { useCallback, useEffect, useState } from 'react'; -export const SpeedControl = ({ setSpeedAndRestart }: { +export const SpeedControl = ({ + setSpeedAndRestart, + setAudioPlayerSpeedAndRestart +}: { setSpeedAndRestart: (speed: number) => void; + setAudioPlayerSpeedAndRestart: (speed: number) => void; }) => { - const { voiceSpeed } = useConfig(); - const [localSpeed, setLocalSpeed] = useState(voiceSpeed); + const { voiceSpeed, audioPlayerSpeed } = useConfig(); + const [localVoiceSpeed, setLocalVoiceSpeed] = useState(voiceSpeed); + const [localAudioSpeed, setLocalAudioSpeed] = useState(audioPlayerSpeed); - // Sync local speed with global state + // Sync local speeds with global state useEffect(() => { - setLocalSpeed(voiceSpeed); + setLocalVoiceSpeed(voiceSpeed); }, [voiceSpeed]); - // Handler for slider change (updates local state only) - const handleSpeedChange = useCallback((event: React.ChangeEvent) => { - setLocalSpeed(parseFloat(event.target.value)); + useEffect(() => { + setLocalAudioSpeed(audioPlayerSpeed); + }, [audioPlayerSpeed]); + + // Handler for voice speed slider change (updates local state only) + const handleVoiceSpeedChange = useCallback((event: React.ChangeEvent) => { + setLocalVoiceSpeed(parseFloat(event.target.value)); }, []); - // Handler for slider release - const handleSpeedChangeComplete = useCallback(() => { - if (localSpeed !== voiceSpeed) { - setSpeedAndRestart(localSpeed); + // Handler for audio player speed slider change (updates local state only) + const handleAudioSpeedChange = useCallback((event: React.ChangeEvent) => { + setLocalAudioSpeed(parseFloat(event.target.value)); + }, []); + + // Handler for voice speed slider release + const handleVoiceSpeedChangeComplete = useCallback(() => { + if (localVoiceSpeed !== voiceSpeed) { + setSpeedAndRestart(localVoiceSpeed); } - }, [localSpeed, voiceSpeed, setSpeedAndRestart]); + }, [localVoiceSpeed, voiceSpeed, setSpeedAndRestart]); + + // Handler for audio player speed slider release + const handleAudioSpeedChangeComplete = useCallback(() => { + if (localAudioSpeed !== audioPlayerSpeed) { + setAudioPlayerSpeedAndRestart(localAudioSpeed); + } + }, [localAudioSpeed, audioPlayerSpeed, setAudioPlayerSpeedAndRestart]); + + // Display the currently active speed (prioritize audio player speed if different from 1.0) + const displaySpeed = localAudioSpeed !== 1.0 ? localAudioSpeed : localVoiceSpeed; return ( - {Number.isInteger(localSpeed) ? localSpeed.toString() : localSpeed.toFixed(1)}x + {Number.isInteger(displaySpeed) ? displaySpeed.toString() : displaySpeed.toFixed(1)}x -
-
- 0.5x - {Number.isInteger(localSpeed) ? localSpeed.toString() : localSpeed.toFixed(1)}x - 3x +
+ {/* Native Model Speed */} +
+
Native model speed
+
+ 0.5x + {Number.isInteger(localVoiceSpeed) ? localVoiceSpeed.toString() : localVoiceSpeed.toFixed(1)}x + 3x +
+ +
+ + {/* Audio Player Speed */} +
+
Audio player speed
+
+ 0.5x + {Number.isInteger(localAudioSpeed) ? localAudioSpeed.toString() : localAudioSpeed.toFixed(1)}x + 3x +
+
-
); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/src/components/player/TTSPlayer.tsx b/src/components/player/TTSPlayer.tsx index e60fb88..07bf938 100644 --- a/src/components/player/TTSPlayer.tsx +++ b/src/components/player/TTSPlayer.tsx @@ -24,6 +24,7 @@ export default function TTSPlayer({ currentPage, numPages }: { skipBackward, isProcessing, setSpeedAndRestart, + setAudioPlayerSpeedAndRestart, setVoiceAndRestart, availableVoices, skipToLocation, @@ -33,7 +34,10 @@ export default function TTSPlayer({ currentPage, numPages }: {
{/* Speed control */} - + {/* Page Navigation */} {currentPage && numPages && ( diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 324c885..f82ab01 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -12,6 +12,7 @@ type ConfigValues = { baseUrl: string; viewType: ViewType; voiceSpeed: number; + audioPlayerSpeed: number; voice: string; skipBlank: boolean; epubTheme: boolean; @@ -29,6 +30,7 @@ interface ConfigContextType { baseUrl: string; viewType: ViewType; voiceSpeed: number; + audioPlayerSpeed: number; voice: string; skipBlank: boolean; epubTheme: boolean; @@ -58,6 +60,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const [baseUrl, setBaseUrl] = useState(''); const [viewType, setViewType] = useState('single'); const [voiceSpeed, setVoiceSpeed] = useState(1); + const [audioPlayerSpeed, setAudioPlayerSpeed] = useState(1); const [voice, setVoice] = useState('af_sarah'); const [skipBlank, setSkipBlank] = useState(true); const [epubTheme, setEpubTheme] = useState(false); @@ -83,6 +86,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const cachedBaseUrl = await getItem('baseUrl'); const cachedViewType = await getItem('viewType'); const cachedVoiceSpeed = await getItem('voiceSpeed'); + const cachedAudioPlayerSpeed = await getItem('audioPlayerSpeed'); const cachedVoice = await getItem('voice'); const cachedSkipBlank = await getItem('skipBlank'); const cachedEpubTheme = await getItem('epubTheme'); @@ -106,6 +110,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { // Set the other values with defaults setViewType((cachedViewType || 'single') as ViewType); setVoiceSpeed(parseFloat(cachedVoiceSpeed || '1')); + setAudioPlayerSpeed(parseFloat(cachedAudioPlayerSpeed || '1')); setVoice(cachedVoice || 'af_sarah'); setSkipBlank(cachedSkipBlank === 'false' ? false : true); setEpubTheme(cachedEpubTheme === 'true'); @@ -136,6 +141,12 @@ export function ConfigProvider({ children }: { children: ReactNode }) { if (cachedTTSInstructions === null) { await setItem('ttsInstructions', ''); } + if (!cachedVoiceSpeed) { + await setItem('voiceSpeed', '1'); + } + if (!cachedAudioPlayerSpeed) { + await setItem('audioPlayerSpeed', '1'); + } } catch (error) { console.error('Error initializing:', error); @@ -151,7 +162,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { * Updates multiple configuration values simultaneously * Only saves API credentials if they are explicitly set */ - const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => { + const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => { try { setIsLoading(true); if (newConfig.apiKey !== undefined || newConfig.apiKey !== '') { @@ -205,6 +216,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) { case 'voiceSpeed': setVoiceSpeed(value as number); break; + case 'audioPlayerSpeed': + setAudioPlayerSpeed(value as number); + break; case 'voice': setVoice(value as string); break; @@ -247,6 +261,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { baseUrl, viewType, voiceSpeed, + audioPlayerSpeed, voice, skipBlank, epubTheme, diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 8d8a6c3..e070a09 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -74,6 +74,7 @@ interface TTSContextType { setText: (text: string, shouldPause?: boolean) => void; setCurrDocPages: (num: number | undefined) => void; setSpeedAndRestart: (speed: number) => void; + setAudioPlayerSpeedAndRestart: (speed: number) => void; setVoiceAndRestart: (voice: string) => void; skipToLocation: (location: string | number, shouldPause?: boolean) => void; registerLocationChangeHandler: (handler: (location: string | number) => void) => void; // EPUB-only: Handles chapter navigation @@ -98,6 +99,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement baseUrl: openApiBaseUrl, isLoading: configIsLoading, voiceSpeed, + audioPlayerSpeed, voice: configVoice, ttsModel: configTTSModel, ttsInstructions: configTTSInstructions, @@ -141,6 +143,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const [currentIndex, setCurrentIndex] = useState(0); const [activeHowl, setActiveHowl] = useState(null); const [speed, setSpeed] = useState(voiceSpeed); + const [audioSpeed, setAudioSpeed] = useState(audioPlayerSpeed); const [voice, setVoice] = useState(configVoice); const [ttsModel, setTTSModel] = useState(configTTSModel); const [ttsInstructions, setTTSInstructions] = useState(configTTSInstructions); @@ -375,7 +378,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const updateVoiceAndSpeed = useCallback(() => { setVoice(configVoice); setSpeed(voiceSpeed); - }, [configVoice, voiceSpeed]); + setAudioSpeed(audioPlayerSpeed); + }, [configVoice, voiceSpeed, audioPlayerSpeed]); /** * Initializes configuration and fetches available voices @@ -558,6 +562,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement html5: true, preload: true, pool: 5, + rate: audioSpeed, onplay: () => { setIsProcessing(false); if ('mediaSession' in navigator) { @@ -654,7 +659,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement advance(); return null; } - }, [isPlaying, advance, activeHowl, processSentence]); + }, [isPlaying, advance, activeHowl, processSentence, audioSpeed]); const playAudio = useCallback(async () => { const howl = await playSentenceWithHowl(sentences[currentIndex]); @@ -810,6 +815,35 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement }); }, [abortAudio, updateConfigKey, audioCache, isPlaying]); + /** + * Sets the audio player speed and restarts the playback + * + * @param {number} newSpeed - The new audio player speed to set + */ + const setAudioPlayerSpeedAndRestart = useCallback((newSpeed: number) => { + const wasPlaying = isPlaying; + + // Set a flag to prevent double audio requests during config update + setIsProcessing(true); + + // First stop any current playback + setIsPlaying(false); + abortAudio(true); // Clear pending requests since speed changed + setActiveHowl(null); + + // Update audio speed and config + setAudioSpeed(newSpeed); + + // Update config after state changes + updateConfigKey('audioPlayerSpeed', newSpeed).then(() => { + setIsProcessing(false); + // Resume playback if it was playing before + if (wasPlaying) { + setIsPlaying(true); + } + }); + }, [abortAudio, updateConfigKey, isPlaying]); + /** * Provides the TTS context value to child components */ @@ -831,6 +865,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setText, setCurrDocPages, setSpeedAndRestart, + setAudioPlayerSpeedAndRestart, setVoiceAndRestart, skipToLocation, registerLocationChangeHandler, @@ -854,6 +889,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setText, setCurrDocPages, setSpeedAndRestart, + setAudioPlayerSpeedAndRestart, setVoiceAndRestart, skipToLocation, registerLocationChangeHandler,