Merge pull request #52 from thepycoder/feature/better-speed-control

This commit is contained in:
Richard Roberson 2025-06-29 15:09:05 -06:00 committed by GitHub
commit 08480f07ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 141 additions and 36 deletions

View file

@ -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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<Popover className="relative">
<PopoverButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
<span>{Number.isInteger(localSpeed) ? localSpeed.toString() : localSpeed.toFixed(1)}x</span>
<span>{Number.isInteger(displaySpeed) ? displaySpeed.toString() : displaySpeed.toFixed(1)}x</span>
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
</PopoverButton>
<PopoverPanel anchor="top" className="absolute z-50 bg-base p-3 rounded-md shadow-lg border border-offbase">
<div className="flex flex-col space-y-2">
<div className="flex justify-between">
<span className="text-xs">0.5x</span>
<span className="text-xs font-bold">{Number.isInteger(localSpeed) ? localSpeed.toString() : localSpeed.toFixed(1)}x</span>
<span className="text-xs">3x</span>
<div className="flex flex-col space-y-4">
{/* Native Model Speed */}
<div className="flex flex-col space-y-2">
<div className="text-xs font-medium text-foreground">Native model speed</div>
<div className="flex justify-between">
<span className="text-xs">0.5x</span>
<span className="text-xs font-bold">{Number.isInteger(localVoiceSpeed) ? localVoiceSpeed.toString() : localVoiceSpeed.toFixed(1)}x</span>
<span className="text-xs">3x</span>
</div>
<Input
type="range"
min="0.5"
max="3"
step="0.1"
value={localVoiceSpeed}
onChange={handleVoiceSpeedChange}
onMouseUp={handleVoiceSpeedChangeComplete}
onKeyUp={handleVoiceSpeedChangeComplete}
onTouchEnd={handleVoiceSpeedChangeComplete}
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
/>
</div>
{/* Audio Player Speed */}
<div className="flex flex-col space-y-2">
<div className="text-xs font-medium text-foreground">Audio player speed</div>
<div className="flex justify-between">
<span className="text-xs">0.5x</span>
<span className="text-xs font-bold">{Number.isInteger(localAudioSpeed) ? localAudioSpeed.toString() : localAudioSpeed.toFixed(1)}x</span>
<span className="text-xs">3x</span>
</div>
<Input
type="range"
min="0.5"
max="3"
step="0.1"
value={localAudioSpeed}
onChange={handleAudioSpeedChange}
onMouseUp={handleAudioSpeedChangeComplete}
onKeyUp={handleAudioSpeedChangeComplete}
onTouchEnd={handleAudioSpeedChangeComplete}
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
/>
</div>
<Input
type="range"
min="0.5"
max="3"
step="0.1"
value={localSpeed}
onChange={handleSpeedChange}
onMouseUp={handleSpeedChangeComplete}
onKeyUp={handleSpeedChangeComplete}
onTouchEnd={handleSpeedChangeComplete}
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
/>
</div>
</PopoverPanel>
</Popover>
);
}
};

View file

@ -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 }: {
<div className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-49 transition-opacity duration-300`}>
<div className="bg-base dark:bg-base rounded-full shadow-lg px-3 sm:px-4 py-0.5 sm:py-1 flex items-center space-x-0.5 sm:space-x-1 relative scale-90 sm:scale-100 border border-offbase">
{/* Speed control */}
<SpeedControl setSpeedAndRestart={setSpeedAndRestart} />
<SpeedControl
setSpeedAndRestart={setSpeedAndRestart}
setAudioPlayerSpeedAndRestart={setAudioPlayerSpeedAndRestart}
/>
{/* Page Navigation */}
{currentPage && numPages && (

View file

@ -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<string>('');
const [viewType, setViewType] = useState<ViewType>('single');
const [voiceSpeed, setVoiceSpeed] = useState<number>(1);
const [audioPlayerSpeed, setAudioPlayerSpeed] = useState<number>(1);
const [voice, setVoice] = useState<string>('af_sarah');
const [skipBlank, setSkipBlank] = useState<boolean>(true);
const [epubTheme, setEpubTheme] = useState<boolean>(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,

View file

@ -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<Howl | null>(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);
@ -387,7 +390,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
@ -570,6 +574,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
html5: true,
preload: true,
pool: 5,
rate: audioSpeed,
onplay: () => {
setIsProcessing(false);
if ('mediaSession' in navigator) {
@ -666,7 +671,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]);
@ -822,6 +827,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
*/
@ -843,6 +877,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setText,
setCurrDocPages,
setSpeedAndRestart,
setAudioPlayerSpeedAndRestart,
setVoiceAndRestart,
skipToLocation,
registerLocationChangeHandler,
@ -866,6 +901,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setText,
setCurrDocPages,
setSpeedAndRestart,
setAudioPlayerSpeedAndRestart,
setVoiceAndRestart,
skipToLocation,
registerLocationChangeHandler,