Skip to content

Commit ecf86c0

Browse files
committed
Fix to enable Gemini TTS voices
1 parent 07f0e1f commit ecf86c0

5 files changed

Lines changed: 321 additions & 63 deletions

File tree

README.md

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,18 @@ One API key covers both Text-to-Speech and Speech-to-Text.
2626

2727
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2828
2. Create a project or select an existing one
29-
3. Enable the [Cloud Text-to-Speech API](https://console.cloud.google.com/apis/library/texttospeech.googleapis.com)
30-
4. Enable the [Cloud Speech-to-Text API](https://console.cloud.google.com/apis/library/speech.googleapis.com)
31-
5. Go to [APIs & Services > Credentials](https://console.cloud.google.com/apis/credentials)
32-
6. Click **Create Credentials > API Key**
33-
7. Copy the key
34-
35-
New accounts get $300 in free credits. TTS pricing is ~$4 per 1M characters for Neural2 voices, ~$16/1M for Gemini/Chirp3-HD.
29+
3. Enable the [Cloud Text-to-Speech API](https://console.cloud.google.com/apis/library/texttospeech.googleapis.com) (for Neural2, Studio, Wavenet, Chirp voices)
30+
4. Enable the [Generative Language API](https://console.cloud.google.com/apis/library/generativelanguage.googleapis.com) (for Gemini voices)
31+
5. Enable the [Cloud Speech-to-Text API](https://console.cloud.google.com/apis/library/speech.googleapis.com) (for transcription)
32+
6. Go to [APIs & Services > Credentials](https://console.cloud.google.com/apis/credentials)
33+
7. Click **Create Credentials > API Key**
34+
8. Click **Edit API Key** and under **API restrictions**, select **Restrict key** and add all three APIs:
35+
- Cloud Text-to-Speech API
36+
- Generative Language API
37+
- Cloud Speech-to-Text API
38+
9. Copy the key
39+
40+
New accounts get $300 in free credits. TTS pricing is ~$4 per 1M characters for Neural2 voices, ~$16/1M for Chirp3-HD.
3641

3742
### ElevenLabs (TTS)
3843

@@ -95,17 +100,19 @@ prompt-tools transcribe --file recording.wav
95100

96101
| Model | Quality | Example Voice | Notes |
97102
|-------|---------|---------------|-------|
98-
| Gemini | Highest | `Achernar`, `Kore`, `Puck` | Bare names, uses Gemini 2.5 Pro |
99-
| Chirp3-HD | High | `en-US-Chirp3-HD-Achernar` | Same voices, different model |
100-
| Studio | High | `en-US-Studio-O` | Studio-grade |
101-
| Neural2 | Good | `en-US-Neural2-F` | Good default |
102-
| Wavenet | Good | `en-US-Wavenet-A` | DeepMind Wavenet |
103-
| Standard | Basic | `en-US-Standard-A` | Concatenative |
103+
| Model | Quality | Example Voice | API Used | Notes |
104+
|-------|---------|---------------|----------|-------|
105+
| Gemini | Highest | `Achernar`, `Kore`, `Puck` | Generative Language | Bare names, auto-selects best model |
106+
| Chirp3-HD | High | `en-US-Chirp3-HD-Achernar` | Cloud TTS | Same voices, different model |
107+
| Studio | High | `en-US-Studio-O` | Cloud TTS | Studio-grade |
108+
| Neural2 | Good | `en-US-Neural2-F` | Cloud TTS | Good default |
109+
| Wavenet | Good | `en-US-Wavenet-A` | Cloud TTS | DeepMind Wavenet |
110+
| Standard | Basic | `en-US-Standard-A` | Cloud TTS | Concatenative |
104111

105-
Gemini voices automatically use `gemini-2.5-pro-tts`. Override with `--model`:
112+
Gemini voices use the [Generative Language API](https://console.cloud.google.com/apis/library/generativelanguage.googleapis.com) (must be enabled separately). The best available Gemini TTS model is auto-selected. Override with `--model`:
106113

107114
```bash
108-
prompt-tools speak "Hello" --voice Kore --model gemini-2.5-flash-tts -o hello.wav
115+
prompt-tools speak "Hello" --voice Kore --model gemini-2.5-flash-preview-tts -o hello.wav
109116
```
110117

111118
### ElevenLabs

cmd/speak.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,16 @@ Google voices use two naming styles:
2525
Structured: en-US-Neural2-F, en-US-Chirp3-HD-Achernar, en-US-Studio-O
2626
Gemini: Achernar, Kore, Puck (bare names — highest quality)
2727
28-
Gemini voices automatically use the gemini-2.5-pro-tts model. Override with --model:
29-
gemini-2.5-pro-tts Highest quality (default for Gemini voices)
30-
gemini-2.5-flash-tts Fast, good quality
31-
gemini-2.5-flash-lite-preview-tts Fastest, lowest cost
28+
Gemini voices automatically use the Generative Language API. Override model with --model:
29+
gemini-2.5-pro-preview-tts Highest quality (default for Gemini voices)
30+
gemini-2.5-flash-preview-tts Fast, good quality
3231
3332
Examples:
3433
prompt-tools speak "Hello world" -o hello.wav
3534
prompt-tools speak "Hello world" --voice Achernar -o hello.wav
3635
prompt-tools speak --ssml "<speak>Hello<break time='500ms'/>world</speak>" -o hello.wav
3736
prompt-tools speak --file script.txt --voice en-US-Studio-O -o prompt.wav
38-
prompt-tools speak "Hello" --voice Kore --model gemini-2.5-flash-tts -o hello.wav`,
37+
prompt-tools speak "Hello" --voice Kore --model gemini-2.5-flash-preview-tts -o hello.wav`,
3938
Args: cobra.MaximumNArgs(1),
4039
RunE: func(cmd *cobra.Command, args []string) error {
4140
text, _ := cmd.Flags().GetString("text")
@@ -171,7 +170,8 @@ Examples:
171170
// Write output
172171
outputData := result.AudioData
173172
ext := strings.ToLower(filepath.Ext(outputPath))
174-
if ext == ".wav" && result.Format != audio.EncodingMP3 {
173+
alreadyWAV := len(result.AudioData) >= 4 && string(result.AudioData[:4]) == "RIFF"
174+
if ext == ".wav" && result.Format != audio.EncodingMP3 && !alreadyWAV {
175175
outputData, err = audio.WriteWAV(result.AudioData, result.SampleRate, result.Format)
176176
if err != nil {
177177
return fmt.Errorf("writing WAV header: %w", err)
@@ -200,7 +200,7 @@ func init() {
200200
speakCmd.Flags().Float64("speaking-rate", 0, "Speaking rate multiplier")
201201
speakCmd.Flags().Float64("pitch", 0, "Pitch in semitones")
202202
speakCmd.Flags().Float64("volume-gain-db", 0, "Volume gain in dB")
203-
speakCmd.Flags().String("model", "", "Gemini model (gemini-2.5-pro-tts, gemini-2.5-flash-tts, gemini-2.5-flash-lite-preview-tts)")
203+
speakCmd.Flags().String("model", "", "Gemini model (gemini-2.5-pro-preview-tts, gemini-2.5-flash-preview-tts)")
204204

205205
rootCmd.AddCommand(speakCmd)
206206
}

internal/audio/convert.go

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,25 @@ func linearToAlaw(sample int16) byte {
8686
return byte((exponent << 4) | mantissa) ^ byte(sign)
8787
}
8888

89-
// ResampleLinear16 resamples 16-bit PCM from srcRate to dstRate using linear interpolation.
89+
// ResampleLinear16 resamples 16-bit PCM from srcRate to dstRate.
90+
// When downsampling, applies a windowed-sinc low-pass filter to prevent aliasing.
9091
func ResampleLinear16(pcmData []byte, srcRate, dstRate int) []byte {
9192
if srcRate == dstRate {
9293
return pcmData
9394
}
9495

9596
numSrcSamples := len(pcmData) / 2
97+
src := make([]float64, numSrcSamples)
98+
for i := 0; i < numSrcSamples; i++ {
99+
src[i] = float64(int16(pcmData[i*2]) | int16(pcmData[i*2+1])<<8)
100+
}
101+
102+
// Apply low-pass filter before downsampling to prevent aliasing.
103+
// Cutoff at Nyquist of the target rate.
104+
if dstRate < srcRate {
105+
src = lowPass(src, srcRate, dstRate/2)
106+
}
107+
96108
ratio := float64(srcRate) / float64(dstRate)
97109
numDstSamples := int(float64(numSrcSamples) / ratio)
98110

@@ -103,12 +115,17 @@ func ResampleLinear16(pcmData []byte, srcRate, dstRate int) []byte {
103115
frac := srcPos - float64(srcIdx)
104116

105117
var sample float64
106-
s0 := int16(pcmData[srcIdx*2]) | int16(pcmData[srcIdx*2+1])<<8
107118
if srcIdx+1 < numSrcSamples {
108-
s1 := int16(pcmData[(srcIdx+1)*2]) | int16(pcmData[(srcIdx+1)*2+1])<<8
109-
sample = float64(s0)*(1-frac) + float64(s1)*frac
119+
sample = src[srcIdx]*(1-frac) + src[srcIdx+1]*frac
110120
} else {
111-
sample = float64(s0)
121+
sample = src[srcIdx]
122+
}
123+
124+
// Clamp to int16 range
125+
if sample > 32767 {
126+
sample = 32767
127+
} else if sample < -32768 {
128+
sample = -32768
112129
}
113130

114131
s := int16(sample)
@@ -117,3 +134,51 @@ func ResampleLinear16(pcmData []byte, srcRate, dstRate int) []byte {
117134
}
118135
return result
119136
}
137+
138+
// lowPass applies a windowed-sinc FIR low-pass filter.
139+
func lowPass(samples []float64, sampleRate, cutoffHz int) []float64 {
140+
// Filter order scales with the ratio for good stopband attenuation
141+
halfLen := sampleRate / cutoffHz * 8
142+
if halfLen < 32 {
143+
halfLen = 32
144+
}
145+
if halfLen > 256 {
146+
halfLen = 256
147+
}
148+
149+
fc := float64(cutoffHz) / float64(sampleRate)
150+
kernel := make([]float64, 2*halfLen+1)
151+
var sum float64
152+
for i := -halfLen; i <= halfLen; i++ {
153+
if i == 0 {
154+
kernel[i+halfLen] = 2 * math.Pi * fc
155+
} else {
156+
x := float64(i)
157+
// Sinc
158+
sinc := math.Sin(2*math.Pi*fc*x) / x
159+
// Blackman window
160+
w := 0.42 - 0.5*math.Cos(2*math.Pi*float64(i+halfLen)/float64(2*halfLen)) + 0.08*math.Cos(4*math.Pi*float64(i+halfLen)/float64(2*halfLen))
161+
kernel[i+halfLen] = sinc * w
162+
}
163+
sum += kernel[i+halfLen]
164+
}
165+
// Normalize
166+
for i := range kernel {
167+
kernel[i] /= sum
168+
}
169+
170+
// Convolve
171+
n := len(samples)
172+
out := make([]float64, n)
173+
for i := 0; i < n; i++ {
174+
var v float64
175+
for j := -halfLen; j <= halfLen; j++ {
176+
idx := i + j
177+
if idx >= 0 && idx < n {
178+
v += samples[idx] * kernel[j+halfLen]
179+
}
180+
}
181+
out[i] = v
182+
}
183+
return out
184+
}

0 commit comments

Comments
 (0)