Generate a Playable WAV File
Discover a voice, synthesize PCM16 with SDK 0.18.0, and package it as a verified WAV artifact.
This recipe turns one text input containing a Unicode letter or number after trimming into a complete file for a media player. It discovers a voice at runtime, stops safely when none is returned, waits for final audio, writes the correct PCM metadata, and closes the client on every path.
When to use this recipe
Use the buffered workflow when the app needs a complete WAV artifact before it publishes, stores, or plays the result. If playback or processing must begin before synthesis finishes, use the streaming method described below and keep the same final-chunk, container, timeout, error, and cleanup boundaries.
This recipe covers Socket.IO TTS through the released JavaScript and Python SDKs. It does not define the output contract for a different transport.
Before you start
| Requirement | 0.18.0 contract |
|---|---|
| SDK | @humain-voice/sdk@0.18.0 or humain-voice==0.18.0 in a trusted server runtime |
| Connection | Provisioned API_URL and API_KEY; SDK defaults to /socket.io |
| Text | After trimming, contains at least one Unicode letter or number; whitespace-only and punctuation-only input is invalid |
| Voice input | Exactly one of voice_id or a non-empty voice_references collection |
| Output | A writable destination for the final WAV artifact |
export API_URL="https://api.voice.humain.com"
export API_KEY="YOUR_API_KEY"The tested path discovers a voice_id; it does not assume that a particular
voice or any voice is available. Keep the API key in the trusted runtime; the
SDK uses /socket.io by default.
If the app uses voice_references instead, send one reference whose audio is
standard-base64 RIFF/WAVE containing non-empty mono PCM16 data.
1. Discover a voice
Call listVoices() / list_voices() before synthesis when the app has not
already been given a supported voice reference.
| Runtime | Released default | This recipe |
|---|---|---|
| JavaScript | listVoices() defaults to 5 seconds | Passes timeoutSeconds: 5 explicitly |
| Python | list_voices() has no timeout unless supplied | Passes timeout_seconds=5.0 |
The result contains seven multilingual { id, label, profile } entries when
all configured variants are available. Treat the list as runtime data:
- If the list is empty, stop this
voice_idworkflow before indexing it. - If a request fails, preserve its structured error state and clean up.
- If a voice is returned, pass its exact
id; do not derive an ID from the label.
Passing a profile ID lets Platform select its physical variant from the text. For current Arabic/English profiles, any Arabic-script letter selects Arabic; otherwise English is selected. Physical variant IDs are internal and rejected.
An empty list is an operational result, not a promise about future voice availability. Do not invent a fallback voice ID.
2. Run the tested happy path
The programs request voices, reject an empty list, select TtsModel.Nebula,
synthesize the text Hello from HUMAIN Voice, derive the model sample rate,
write speech.wav, and close the client.
import { writeFile } from 'node:fs/promises';
import {
TTSClient,
TtsModel,
getSampleRate,
} from '@humain-voice/sdk';
function requiredEnv(name: 'API_KEY' | 'API_PATH' | 'API_URL'): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}
function pcm16ToWav(pcm: Uint8Array, sampleRate: number): Buffer {
const header = Buffer.alloc(44);
header.write('RIFF', 0);
header.writeUInt32LE(36 + pcm.byteLength, 4);
header.write('WAVE', 8);
header.write('fmt ', 12);
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20); // Linear PCM.
header.writeUInt16LE(1, 22); // Mono.
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(sampleRate * 2, 28);
header.writeUInt16LE(2, 32);
header.writeUInt16LE(16, 34);
header.write('data', 36);
header.writeUInt32LE(pcm.byteLength, 40);
return Buffer.concat([
header,
Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength),
]);
}
async function main(): Promise<void> {
const outputPath = process.argv[2] ?? 'speech.wav';
const client = new TTSClient({
api_url: requiredEnv('API_URL'),
api_path: requiredEnv('API_PATH'),
api_key: requiredEnv('API_KEY'),
});
try {
const voices = await client.listVoices({ timeoutSeconds: 5 });
const voice = voices.find((candidate) => candidate.profile) ?? voices[0];
if (!voice) throw new Error('No TTS voices are available');
if (voice.profile) {
console.log(
'profile:',
voice.label,
voice.profile.speaker.dialect,
voice.profile.languages,
);
}
const model = TtsModel.Nebula;
const pcm = await client.synthesize('Hello from HUMAIN Voice', {
voice_id: voice.id,
model,
// This is an inactivity timeout applied while awaiting each audio chunk.
timeoutSeconds: 30,
onError: (error) => console.error('server error:', error.code, error.message),
});
await writeFile(outputPath, pcm16ToWav(pcm, getSampleRate(model)));
} finally {
await client.close();
}
}
void main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
A successful run has these observable outcomes:
- Voice discovery returns at least one entry for this request.
- Buffered synthesis receives the final audio chunk without a recorded server error or inactivity timeout.
speech.wavstarts with a valid RIFF/WAVE header followed by all returned PCM bytes and can be opened by a WAV-capable player.- The JavaScript client closes in
finally; Python exits its async client context before writing the artifact.
If voice discovery returns no entries, the programs fail clearly and do not create a misleading silent artifact.
3. Package raw PCM as WAV
The SDK return value is audio data, not a ready-made media file:
| Layer | Value used by the fixtures |
|---|---|
| SDK audio | Raw signed PCM16 little-endian, 24 kHz, mono |
| Sample width | 16 bits, or 2 bytes |
| WAV header | 44-byte RIFF/WAVE header with PCM format, channel count, sample rate, byte rate, block alignment, and data length |
| WAV body | Every PCM byte returned after synthesis reaches its final chunk |
Both programs obtain the sample rate through getSampleRate(model) /
get_sample_rate(model) instead of treating a container header as part of the
SDK response.
The JavaScript helper accepts the released Uint8Array directly and preserves
its byteOffset and byteLength when creating a Node.js Buffer. Python uses
the standard-library wave module to write the same metadata.
The 24 kHz PCM contract applies to Socket.IO TTS and these SDK clients. Read the current operation contract before packaging bytes returned by another transport.
4. Choose buffered or streaming synthesis
| Mode | API | Result and responsibility |
|---|---|---|
| Buffered | synthesize() | Returns one Uint8Array in JavaScript or bytes in Python after the SDK has collected audio through the final chunk. Simple for bounded files, but holds the complete PCM result in memory. |
| Streaming | synthesizeStream() / synthesize_stream() | Yields responses with id, audio, and is_last; process audio earlier, track total bytes, and finalize a valid container only after the final response. |
For either mode, the SDK has already removed the 17-byte Socket.IO TTS frame
header. Write each response's audio bytes, not the original framed payload.
An arbitrary network or iterator boundary is not completion; is_last=true
marks the final audio response.
Buffered synthesis can also receive chunks through onAudio / on_audio, but
the buffered call itself completes only after final audio is collected. If a
stream ends without a final chunk, do not publish the partial file as a complete
artifact.
5. Bound inactivity and preserve error state
| Runtime | Voice-list timeout | Synthesis timeout |
|---|---|---|
| JavaScript | 5-second default | timeoutSeconds defaults to 30 seconds of inactivity |
| Python | No default | No default; always pass timeout_seconds |
The fixtures explicitly use five seconds for voice discovery and 30 seconds for synthesis. The synthesis value is an inactivity timeout while waiting for the next audio chunk, not an overall workflow deadline. Add a separate app deadline for connection, discovery, synthesis, file writing, and cleanup.
The service independently enforces a non-resetting 25-second overall synthesis
deadline and a 60-second inactivity watchdog. If the overall deadline wins
before final audio, it emits retryable TTS_DEADLINE_EXCEEDED; audio already
received remains partial.
TTS exposes two different error surfaces:
| Surface | Information retained |
|---|---|
onError / on_error callback | Normalized ErrorResponse; structured payloads can retain id, code, retryable, timestamp, retry_after_seconds, data, reason, retry_scope, and message. A legacy non-object payload becomes a message. |
| Rejected synthesis | Generic JavaScript Error or Python RuntimeError containing the message only |
Record structured callback fields before cleanup. Do not infer retryability by parsing the generic rejection message, and do not claim a retry is safe when the callback does not provide enough state.
6. Clean up and publish atomically
Close the client even when voice discovery is empty, synthesis fails, the final
chunk never arrives, or file writing fails. The JavaScript fixture calls
client.close() in finally; the Python fixture uses async with to release
Socket.IO and its underlying HTTP resources.
Cleanup closes the client connection, which cancels active synthesis requests owned by that connection and suppresses their later audio and error events. It does not close inference connections shared with other requests.
For a production file workflow, write to a temporary destination and expose the artifact only after final audio, a complete WAV header/body, and a successful file close. Cleanup is still required if publishing the file fails.
Next steps
Review TTS models
Use the released model value and discover voices at runtime without availability assumptions.
Inspect TTS events
Review exact voice-list, request, audio-frame, final-chunk, and error schemas.
Handle synthesis failures
Classify structured errors, bound inactivity, and retry only what is safe to repeat.