Skip to content

Commit c4b6904

Browse files
refactor: separate audio playback from TTS providers
- Add audio.ts with typed OperatingSystem union and switch cases - Providers now use shared playAudio() instead of handling OS specifics - ElevenLabs: use getters for env vars (fixes timing issue) - Piper: remove duplicated playAudio, IS_WSL boolean, PowerShell code
1 parent 3e1f895 commit c4b6904

File tree

3 files changed

+62
-64
lines changed

3 files changed

+62
-64
lines changed

.claude/voice-server/audio.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { spawn } from 'child_process';
2+
import { platform, release } from 'os';
3+
4+
export type OperatingSystem = 'macos' | 'wsl' | 'linux';
5+
6+
function detectOS(): OperatingSystem {
7+
const p = platform();
8+
if (p === 'darwin') return 'macos';
9+
if (p === 'linux') {
10+
return release().toLowerCase().includes('microsoft') ? 'wsl' : 'linux';
11+
}
12+
throw new Error(`Unsupported platform: ${p}`);
13+
}
14+
15+
export const OS: OperatingSystem = detectOS();
16+
17+
export async function playAudio(audio: Buffer, format: 'mp3' | 'wav'): Promise<void> {
18+
const tempFile = `/tmp/voice-${Date.now()}.${format}`;
19+
await Bun.write(tempFile, audio);
20+
21+
const player = getAudioPlayer(tempFile);
22+
23+
return new Promise((resolve, reject) => {
24+
const proc = spawn(player.cmd, player.args);
25+
proc.on('error', reject);
26+
proc.on('exit', (code) => {
27+
spawn('/bin/rm', ['-f', tempFile]);
28+
code === 0 ? resolve() : reject(new Error(`${player.cmd} exited ${code}`));
29+
});
30+
});
31+
}
32+
33+
function getAudioPlayer(file: string): { cmd: string; args: string[] } {
34+
switch (OS) {
35+
case 'macos':
36+
return { cmd: '/usr/bin/afplay', args: [file] };
37+
case 'wsl':
38+
case 'linux':
39+
return { cmd: 'paplay', args: [file] };
40+
default:
41+
const _exhaustive: never = OS;
42+
throw new Error(`Unhandled OS: ${_exhaustive}`);
43+
}
44+
}
Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,35 @@
1-
import { spawn } from 'child_process';
2-
import type { TTSProvider } from './index';
3-
4-
const ELEVENLABS_API_KEY = process.env.ELEVENLABS_API_KEY;
5-
const DEFAULT_VOICE_ID = process.env.ELEVENLABS_VOICE_ID || 's3TPKV1kjDlVtZbl4Ksh';
6-
const DEFAULT_MODEL = process.env.ELEVENLABS_MODEL || 'eleven_multilingual_v2';
1+
import type { TTSProvider } from '.';
2+
import { playAudio } from '../audio';
73

84
export class ElevenLabs implements TTSProvider {
95
readonly name = 'elevenlabs';
106

7+
private get apiKey() { return process.env.ELEVENLABS_API_KEY; }
8+
private get voiceId() { return process.env.ELEVENLABS_VOICE_ID || 's3TPKV1kjDlVtZbl4Ksh'; }
9+
private get model() { return process.env.ELEVENLABS_MODEL || 'eleven_multilingual_v2'; }
10+
1111
isAvailable(): boolean {
12-
return !!ELEVENLABS_API_KEY;
12+
return !!this.apiKey;
1313
}
1414

1515
async speak(text: string, voiceId?: string): Promise<void> {
16-
if (!ELEVENLABS_API_KEY) {
16+
if (!this.apiKey) {
1717
throw new Error('ElevenLabs API key not configured');
1818
}
1919

20-
const voice = voiceId || DEFAULT_VOICE_ID;
20+
const voice = voiceId || this.voiceId;
2121
const url = `https://api.elevenlabs.io/v1/text-to-speech/${voice}`;
2222

2323
const response = await fetch(url, {
2424
method: 'POST',
2525
headers: {
2626
'Accept': 'audio/mpeg',
2727
'Content-Type': 'application/json',
28-
'xi-api-key': ELEVENLABS_API_KEY,
28+
'xi-api-key': this.apiKey,
2929
},
3030
body: JSON.stringify({
31-
text: text,
32-
model_id: DEFAULT_MODEL,
31+
text,
32+
model_id: this.model,
3333
voice_settings: {
3434
stability: 0.5,
3535
similarity_boost: 0.5,
@@ -40,26 +40,12 @@ export class ElevenLabs implements TTSProvider {
4040
if (!response.ok) {
4141
const errorText = await response.text();
4242
if (errorText.includes('model') || response.status === 422) {
43-
throw new Error(`ElevenLabs API error: Invalid model "${DEFAULT_MODEL}". Update ELEVENLABS_MODEL in ~/.env. See https://elevenlabs.io/docs/models`);
43+
throw new Error(`ElevenLabs API error: Invalid model "${this.model}". Update ELEVENLABS_MODEL in ~/.env`);
4444
}
4545
throw new Error(`ElevenLabs API error: ${response.status} - ${errorText}`);
4646
}
4747

4848
const audioBuffer = await response.arrayBuffer();
49-
const tempFile = `/tmp/voice-${Date.now()}.mp3`;
50-
await Bun.write(tempFile, audioBuffer);
51-
52-
return new Promise((resolve, reject) => {
53-
const proc = spawn('/usr/bin/afplay', [tempFile]);
54-
proc.on('error', (error) => {
55-
console.error('Error playing audio:', error);
56-
reject(error);
57-
});
58-
proc.on('exit', (code) => {
59-
spawn('/bin/rm', [tempFile]);
60-
if (code === 0) resolve();
61-
else reject(new Error(`afplay exited with code ${code}`));
62-
});
63-
});
49+
await playAudio(Buffer.from(audioBuffer), 'mp3');
6450
}
6551
}

.claude/voice-server/providers/Piper.ts

Lines changed: 4 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import { spawn, spawnSync } from 'child_process';
1+
import { spawnSync } from 'child_process';
22
import { existsSync, readFileSync } from 'fs';
33
import { join } from 'path';
4-
import { platform, release } from 'os';
5-
import type { TTSProvider } from './index';
6-
7-
const IS_WSL = platform() === 'linux' && release().toLowerCase().includes('microsoft');
4+
import type { TTSProvider } from '.';
5+
import { playAudio } from '../audio';
86

97
interface VoiceConfig {
108
model: string;
@@ -55,7 +53,7 @@ export class Piper implements TTSProvider {
5553
if (result.status !== 0) throw new Error(`Piper failed: ${result.stderr?.toString()}`);
5654

5755
const wavBuffer = this.pcmToWav(result.stdout);
58-
await this.playAudio(wavBuffer);
56+
await playAudio(wavBuffer, 'wav');
5957
}
6058

6159
private pcmToWav(pcm: Buffer): Buffer {
@@ -75,34 +73,4 @@ export class Piper implements TTSProvider {
7573
header.writeUInt32LE(pcm.length, 40);
7674
return Buffer.concat([header, pcm]);
7775
}
78-
79-
private async playAudio(wav: Buffer): Promise<void> {
80-
const tempFile = `/tmp/voice-${Date.now()}.wav`;
81-
await Bun.write(tempFile, wav);
82-
83-
if (IS_WSL) {
84-
const winPath = `C:\\Users\\Public\\piper_${Date.now()}.wav`;
85-
const wslPath = `/mnt/c/Users/Public/piper_${Date.now()}.wav`;
86-
await Bun.write(wslPath, wav);
87-
88-
return new Promise((resolve, reject) => {
89-
const proc = spawn('powershell.exe', ['-NoProfile', '-Command',
90-
`(New-Object Media.SoundPlayer '${winPath}').PlaySync(); Remove-Item '${winPath}'`]);
91-
proc.on('error', reject);
92-
proc.on('exit', (code) => {
93-
spawn('/bin/rm', ['-f', tempFile]);
94-
code === 0 ? resolve() : reject(new Error(`powershell exited ${code}`));
95-
});
96-
});
97-
}
98-
99-
return new Promise((resolve, reject) => {
100-
const proc = spawn('aplay', ['-q', tempFile]);
101-
proc.on('error', reject);
102-
proc.on('exit', (code) => {
103-
spawn('/bin/rm', ['-f', tempFile]);
104-
code === 0 ? resolve() : reject(new Error(`aplay exited ${code}`));
105-
});
106-
});
107-
}
10876
}

0 commit comments

Comments
 (0)