@@ -8,40 +8,135 @@ import {
88} from '@headlessui/react' ;
99import { ChevronUpDownIcon , AudioWaveIcon } from '@/components/icons/Icons' ;
1010import { useConfig } from '@/contexts/ConfigContext' ;
11+ import { useEffect , useMemo , useState , useCallback } from 'react' ;
12+ import {
13+ parseKokoroVoiceNames ,
14+ buildKokoroVoiceString ,
15+ getMaxVoicesForProvider ,
16+ isKokoroModel
17+ } from '@/utils/voice' ;
1118
1219export const VoicesControl = ( { availableVoices, setVoiceAndRestart } : {
1320 availableVoices : string [ ] ;
1421 setVoiceAndRestart : ( voice : string ) => void ;
1522} ) => {
16- const { voice : configVoice } = useConfig ( ) ;
23+ const { voice : configVoice , ttsModel , ttsProvider } = useConfig ( ) ;
1724
18- // If the saved voice is not in the available list, use the first available voice
19- const currentVoice = ( configVoice && availableVoices . includes ( configVoice ) )
20- ? configVoice
21- : availableVoices [ 0 ] || '' ;
25+ const isKokoro = isKokoroModel ( ttsModel ) ;
26+ const maxVoices = getMaxVoicesForProvider ( ttsProvider , ttsModel || '' ) ;
27+
28+ const clampToLimit = useCallback ( ( names : string [ ] ) : string [ ] => {
29+ if ( maxVoices === Infinity ) return names ;
30+ if ( names . length <= maxVoices ) return names ;
31+ // For initial clamp, keep the first up to max allowed
32+ return names . slice ( 0 , maxVoices ) ;
33+ } , [ maxVoices ] ) ;
34+
35+ // Local selection state for Kokoro multi-select
36+ const [ selectedVoices , setSelectedVoices ] = useState < string [ ] > ( [ ] ) ;
37+
38+ useEffect ( ( ) => {
39+ if ( ! isKokoro ) return ;
40+ let initial : string [ ] = [ ] ;
41+ if ( configVoice && configVoice . includes ( '+' ) ) {
42+ initial = parseKokoroVoiceNames ( configVoice ) ;
43+ } else if ( configVoice && availableVoices . includes ( configVoice ) ) {
44+ initial = [ configVoice ] ;
45+ } else if ( availableVoices . length > 0 ) {
46+ initial = [ availableVoices [ 0 ] ] ;
47+ }
48+ setSelectedVoices ( clampToLimit ( initial ) ) ;
49+ } , [ isKokoro , configVoice , availableVoices , maxVoices , clampToLimit ] ) ;
50+
51+ // If the saved voice is not in the available list, use the first available voice (non-Kokoro)
52+ const currentVoice = useMemo ( ( ) => {
53+ if ( isKokoro ) {
54+ const combined = buildKokoroVoiceString ( selectedVoices ) ;
55+ return combined || ( availableVoices [ 0 ] || '' ) ;
56+ }
57+ return ( configVoice && availableVoices . includes ( configVoice ) )
58+ ? configVoice
59+ : availableVoices [ 0 ] || '' ;
60+ } , [ isKokoro , selectedVoices , availableVoices , configVoice ] ) ;
2261
2362 return (
2463 < div className = "relative" >
25- < Listbox value = { currentVoice } onChange = { setVoiceAndRestart } >
26- < ListboxButton 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 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" >
27- < AudioWaveIcon className = "h-3 w-3 sm:h-3.5 sm:w-3.5" />
28- < span > { currentVoice } </ span >
29- < ChevronUpDownIcon className = "h-2.5 w-2.5 sm:h-3 sm:w-3" />
30- </ ListboxButton >
31- < ListboxOptions anchor = 'top end' className = "absolute z-50 w-28 sm:w-32 max-h-64 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" >
32- { availableVoices . map ( ( voiceId ) => (
33- < ListboxOption
34- key = { voiceId }
35- value = { voiceId }
36- className = { ( { active, selected } ) =>
37- `relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${ active ? 'bg-offbase' : '' } ${ selected ? 'font-medium' : '' } `
64+ { isKokoro ? (
65+ < Listbox
66+ multiple
67+ value = { selectedVoices }
68+ onChange = { ( vals : string [ ] ) => {
69+ if ( ! vals || vals . length === 0 ) return ; // prevent empty selection
70+
71+ let next = vals ;
72+
73+ // Enforce deepinfra max selection of 2 voices
74+ if ( maxVoices !== Infinity && vals . length > maxVoices ) {
75+ // Determine the newly added voice
76+ const newlyAdded = vals . find ( v => ! selectedVoices . includes ( v ) ) ;
77+ if ( newlyAdded ) {
78+ const lastPrev = selectedVoices [ selectedVoices . length - 1 ] ?? selectedVoices [ 0 ] ?? '' ;
79+ // Build next as [last previously selected, newly added], deduped, limited to max
80+ const pair = Array . from ( new Set ( [ lastPrev , newlyAdded ] ) ) . filter ( Boolean ) ;
81+ next = pair . slice ( 0 , maxVoices ) ;
82+ } else {
83+ // Fallback: keep the last maxVoices options
84+ next = vals . slice ( - maxVoices ) ;
3885 }
39- >
40- < span className = 'text-xs sm:text-sm' > { voiceId } </ span >
41- </ ListboxOption >
42- ) ) }
43- </ ListboxOptions >
44- </ Listbox >
86+ }
87+
88+ setSelectedVoices ( next ) ;
89+ const combined = buildKokoroVoiceString ( next ) ;
90+ if ( combined ) {
91+ setVoiceAndRestart ( combined ) ;
92+ }
93+ } }
94+ >
95+ < ListboxButton 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 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" >
96+ < AudioWaveIcon className = "h-3 w-3 sm:h-3.5 sm:w-3.5" />
97+ < span >
98+ { selectedVoices . length > 1
99+ ? selectedVoices . join ( ' + ' )
100+ : selectedVoices [ 0 ] || currentVoice }
101+ </ span >
102+ < ChevronUpDownIcon className = "h-2.5 w-2.5 sm:h-3 sm:w-3" />
103+ </ ListboxButton >
104+ < ListboxOptions anchor = 'top end' className = "absolute z-50 w-40 sm:w-44 max-h-64 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" >
105+ { availableVoices . map ( ( voiceId ) => (
106+ < ListboxOption
107+ key = { voiceId }
108+ value = { voiceId }
109+ className = { ( { active, selected } ) =>
110+ `relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${ active ? 'bg-offbase' : '' } ${ selected ? 'font-medium bg-accent text-background' : '' } ${ selected && active ? 'text-foreground' : '' } `
111+ }
112+ >
113+ < span className = 'text-xs sm:text-sm' > { voiceId } </ span >
114+ </ ListboxOption >
115+ ) ) }
116+ </ ListboxOptions >
117+ </ Listbox >
118+ ) : (
119+ < Listbox value = { currentVoice } onChange = { setVoiceAndRestart } >
120+ < ListboxButton 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 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" >
121+ < AudioWaveIcon className = "h-3 w-3 sm:h-3.5 sm:w-3.5" />
122+ < span > { currentVoice } </ span >
123+ < ChevronUpDownIcon className = "h-2.5 w-2.5 sm:h-3 sm:w-3" />
124+ </ ListboxButton >
125+ < ListboxOptions anchor = 'top end' className = "absolute z-50 w-28 sm:w-32 max-h-64 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" >
126+ { availableVoices . map ( ( voiceId ) => (
127+ < ListboxOption
128+ key = { voiceId }
129+ value = { voiceId }
130+ className = { ( { active, selected } ) =>
131+ `relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${ active ? 'bg-offbase' : '' } ${ selected ? 'font-medium bg-accent text-background' : '' } ${ selected && active ? 'text-foreground' : '' } `
132+ }
133+ >
134+ < span className = 'text-xs sm:text-sm' > { voiceId } </ span >
135+ </ ListboxOption >
136+ ) ) }
137+ </ ListboxOptions >
138+ </ Listbox >
139+ ) }
45140 </ div >
46141 ) ;
47142}
0 commit comments