Skip to content

Commit a192d96

Browse files
authored
Merge pull request #3 from HumeAI/twitchard/add-voice-list-delete
Add `hume voices list` and `hume voices delete`
2 parents 9283c02 + 59034be commit a192d96

File tree

13 files changed

+632
-203
lines changed

13 files changed

+632
-203
lines changed

README.md

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ hume login
1616
hume tts "Are you serious?" --description "whispered, hushed"
1717
hume voices create --name whisperer --last
1818
hume tts "I said, are you serious?" --voice-name whisperer
19+
hume voices list # View your saved voices
20+
hume voices list --provider HUME_AI # View Hume's voice library
21+
hume voices delete --name whisperer # Delete a voice when no longer needed
1922
```
2023

2124
## Installation
@@ -28,7 +31,6 @@ npm install -g @humeai/cli
2831

2932
## Usage
3033

31-
```
3234
Text to speech
3335

3436
━━━ Usage ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -53,7 +55,7 @@ $ hume tts <text>
5355
--json Output in JSON format
5456
--pretty Output in human-readable format
5557
--base-url #0 Override the default API base URL (for testing purposes)
56-
--preset-voice Required to use one of Hume's provided voices
58+
--provider #0 Voice provider type (CUSTOM_VOICE or HUME_AI)
5759
--speed #0 Speaking speed multiplier (0.25-3.0, default is 1.0)
5860
--trailing-silence #0 Seconds of silence to add at the end (0.0-5.0, default is 0.35)
5961
--streaming Use streaming mode for TTS generation (default: true)
@@ -75,6 +77,9 @@ Saving a voice you like (see `hume voices create --help`)
7577
Using a previously-saved voice
7678
$ hume tts "Thanks for the 100,000,000,000 likes guys!" -v influencer_1
7779

80+
Using a voice from the Hume Voice Library
81+
$ hume tts "Hello there" -v narrator --provider HUME_AI
82+
7883
Reading from stdin
7984
$ echo "I wouldn't be here without you" | hume tts - -v influencer_1
8085

@@ -97,8 +102,50 @@ Adjusting speech speed
97102
Adding trailing silence
98103
$ hume tts "Wait for it..." -v narrator --trailing-silence 3.5
99104

105+
## Voice Management
106+
107+
The CLI provides commands to manage your custom voices:
108+
109+
### Creating Voices
110+
111+
Save a voice from a previous generation:
112+
113+
```shell
114+
# Create a voice from the last generation
115+
hume voices create --name my-narrator --last
116+
117+
# Create a voice from a specific generation ID
118+
hume voices create --name my-narrator --generation-id abc123
119+
````
120+
121+
### Listing Voices
122+
123+
List your custom voices:
124+
125+
```shell
126+
# List your custom voices
127+
hume voices list
128+
129+
# List voices from the Hume Voice Library
130+
hume voices list --provider HUME_AI
131+
```
132+
133+
### Deleting Voices
134+
135+
Delete a voice by name:
136+
137+
```shell
138+
hume voices delete --name my-narrator
139+
```
140+
100141
━━━ See also ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
101-
* `hume voices create --help` - Save a voice for later use
102-
* `hume session --help` - Save settings temporarily so you don't have to repeat yourself
103-
* `hume config --help` - Save settings more permanently
142+
143+
- `hume voices create --help` - Save a voice for later use
144+
- `hume voices list --help` - List available voices
145+
- `hume voices delete --help` - Delete a saved voice
146+
- `hume session --help` - Save settings temporarily so you don't have to repeat yourself
147+
- `hume config --help` - Save settings more permanently
148+
149+
```
150+
104151
```

bun.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@humeai/cli",
3-
"version": "0.0.4",
3+
"version": "0.0.5",
44
"module": "index.ts",
55
"type": "module",
66
"description": "CLI for Hume.ai's OCTAVE expressive TTS API",
@@ -26,7 +26,7 @@
2626
"bun": "^1.2.2",
2727
"clipanion": "^4.0.0-rc.4",
2828
"debug": "^4.4.0",
29-
"hume": "^0.9.17",
29+
"hume": "^0.10.0",
3030
"open": "^10.1.0",
3131
"typanion": "^3.14.0"
3232
},

src/common.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface Reporter {
2828
mode: string;
2929
json: (data: unknown) => void;
3030
info: (message: string) => void;
31+
warn: (message: string) => void;
3132
withSpinner: <T>(message: string, callback: () => Promise<T>) => Promise<T>;
3233
}
3334

@@ -54,6 +55,7 @@ export const makeReporter = (opts: { mode: 'json' | 'pretty' }): Reporter => {
5455
mode: opts.mode,
5556
json: (data) => printJson(data),
5657
info: () => {},
58+
warn: () => {},
5759
withSpinner: async <T>(_: string, callback: () => Promise<T>): Promise<T> => {
5860
return await callback();
5961
},
@@ -64,6 +66,7 @@ export const makeReporter = (opts: { mode: 'json' | 'pretty' }): Reporter => {
6466
mode: opts.mode,
6567
json: () => {},
6668
info: (message) => clack.log.success(message),
69+
warn: (message) => clack.log.warn(message),
6770
withSpinner: async <T>(message: string, callback: () => Promise<T>): Promise<T> => {
6871
spin.start(message);
6972
try {

src/config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ export type ConfigData = {
2828
last?: boolean;
2929
lastIndex?: number;
3030
playCommand?: string;
31-
presetVoice?: boolean;
31+
presetVoice?: boolean; // Hidden legacy option
32+
provider?: 'CUSTOM_VOICE' | 'HUME_AI';
3233
speed?: number;
3334
trailingSilence?: number;
3435
streaming?: boolean;
@@ -47,7 +48,9 @@ export const configValidators = {
4748
'tts.play': t.isEnum(['all', 'first', 'off'] as const),
4849
'tts.format': t.isEnum(['wav', 'mp3', 'pcm'] as const),
4950
'tts.playCommand': t.isString(),
51+
// Hidden but still valid for backward compatibility
5052
'tts.presetVoice': t.isBoolean(),
53+
'tts.provider': t.isEnum(['CUSTOM_VOICE', 'HUME_AI'] as const),
5154
'tts.speed': t.cascade(t.isNumber(), t.isInInclusiveRange(0.25, 3.0)),
5255
'tts.trailingSilence': t.cascade(t.isNumber(), t.isInInclusiveRange(0.0, 5.0)),
5356
'tts.streaming': t.isBoolean(),

src/e2e.test.ts

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,20 @@ class TestEnvironment {
9191
return this.server.findRequestsTo('/v0/tts/stream/json');
9292
}
9393

94+
/**
95+
* Get the list voices requests
96+
*/
97+
getListVoicesRequests() {
98+
return this.server.findRequestsTo('/v0/tts/voices').filter((req) => req.method === 'GET');
99+
}
100+
101+
/**
102+
* Get the delete voice requests
103+
*/
104+
getDeleteVoiceRequests() {
105+
return this.server.findRequestsTo('/v0/tts/voices').filter((req) => req.method === 'DELETE');
106+
}
107+
94108
/**
95109
* Run a CLI command for testing
96110
*/
@@ -327,9 +341,9 @@ class MockHumeServer {
327341
this.setupDefaultTtsStreamHandler();
328342
}
329343

330-
// Default TTS response handler
344+
// Default handlers for all endpoints
331345
setupDefaultTtsStreamHandler() {
332-
// Handle TTS API requests - actual path used by the client
346+
// TTS stream API endpoint
333347
this.addHandler('/v0/tts/stream/json', async (req) => {
334348
try {
335349
const body = await req.json();
@@ -372,6 +386,53 @@ class MockHumeServer {
372386
});
373387
}
374388
});
389+
390+
// Add handler for voices endpoint
391+
this.addHandler('/v0/tts/voices', async (req) => {
392+
// Check if it's a GET or DELETE request
393+
if (req.method === 'GET') {
394+
// For listing voices
395+
const url = new URL(req.url);
396+
const provider = url.searchParams.get('provider') || 'CUSTOM_VOICE';
397+
398+
let voices = [];
399+
if (provider === 'CUSTOM_VOICE') {
400+
voices = [
401+
{ id: 'custom1', name: 'my-narrator', createdAt: '2023-01-01T00:00:00Z' },
402+
{ id: 'custom2', name: 'my-assistant', createdAt: '2023-01-02T00:00:00Z' },
403+
];
404+
} else {
405+
voices = [
406+
{ id: 'shared1', name: 'hume-narrator', createdAt: '2023-01-01T00:00:00Z' },
407+
{ id: 'shared2', name: 'hume-assistant', createdAt: '2023-01-02T00:00:00Z' },
408+
{ id: 'shared3', name: 'hume-podcaster', createdAt: '2023-01-03T00:00:00Z' },
409+
];
410+
}
411+
412+
return Response.json({ data: voices });
413+
} else if (req.method === 'DELETE') {
414+
// For deleting a voice
415+
const url = new URL(req.url);
416+
const name = url.searchParams.get('name');
417+
418+
if (!name) {
419+
return new Response(JSON.stringify({ error: 'Missing name parameter' }), { status: 400 });
420+
}
421+
422+
return Response.json({ success: true });
423+
} else if (req.method === 'POST') {
424+
// For saving a voice (already implemented in the original code)
425+
const body = await req.json();
426+
return Response.json({
427+
id: 'new-voice-123',
428+
name: body.name,
429+
createdAt: new Date().toISOString(),
430+
});
431+
}
432+
433+
// Fallback
434+
return new Response('Method not supported', { status: 405 });
435+
});
375436
}
376437
}
377438

@@ -758,4 +819,43 @@ describe('CLI End-to-End Tests', () => {
758819
expect(continuationRequests[0].body.context?.generation_id).toBe('config_test_gen_2'); // Should use the second generation
759820
expect(continuationRequests[0].body.format?.type).toBe('mp3'); // Should still use mp3 from config
760821
});
822+
823+
// Voice management command tests
824+
test('Voice list command structure', async () => {
825+
// We're only checking the command structure, not the actual API call
826+
const result = await testEnv.runCliCommand(['voices', 'list', '--help']);
827+
expect(result.exitCode).toBe(0);
828+
expect(result.stdout).toContain('List available voices');
829+
expect(result.stdout).toContain('--provider');
830+
});
831+
832+
test('Voice list with provider option', async () => {
833+
// Test that the provider option is recognized
834+
const result = await testEnv.runCliCommand([
835+
'voices',
836+
'list',
837+
'--provider',
838+
'HUME_AI',
839+
'--help',
840+
]);
841+
expect(result.exitCode).toBe(0);
842+
expect(result.stdout).toContain('List available voices');
843+
expect(result.stdout).toContain('--provider');
844+
});
845+
846+
test('Voice delete command structure', async () => {
847+
// We're only checking the command structure, not the actual API call
848+
const result = await testEnv.runCliCommand(['voices', 'delete', '--help']);
849+
expect(result.exitCode).toBe(0);
850+
expect(result.stdout).toContain('Delete a saved voice');
851+
expect(result.stdout).toContain('--name');
852+
});
853+
854+
test('Error when deleting a voice without name', async () => {
855+
const result = await testEnv.runCliCommand(['voices', 'delete']);
856+
expect(result.exitCode).not.toBe(0);
857+
// Test that we get an error, but don't be specific about the message
858+
// since the error format might vary
859+
expect(result.stderr.length).toBeGreaterThan(0);
860+
});
761861
});

0 commit comments

Comments
 (0)