Skip to content
KugelAudioDocs

Speech Enhancement

Remove background noise or keep one voice with the Python, TypeScript, Rust, C++ and Dart SDKs

client.enhance sends speech to Clarity (clarity-1) and returns it cleaned: background noise removed, or, when you pass a speaker sample, only that person's voice kept. What the two tasks do, the free month, the paid plan and the limits are in the Speech enhancement guide; the wire protocol is in the API reference.

generate and stream are async; generate_sync and stream_sync are their blocking twins and take the same arguments.

Enhance a recording

import asyncio

from kugelaudio import KugelAudio, load_audio

client = KugelAudio(api_key="YOUR_API_KEY")

async def main():
    result = await client.enhance.generate(load_audio("call.wav"), model="clarity-1")
    print(result.duration, "seconds")
    result.save("call-clean.wav")

asyncio.run(main())

# Without asyncio:
result = client.enhance.generate_sync(load_audio("call.wav"), model="clarity-1")
result.save("call-clean.wav")

Keep one voice

Pass a 2 to 8 second sample of the person to keep as the speaker. The SDK then switches to target_speaker_extraction; see A good speaker sample.

result = client.enhance.generate_sync(
    load_audio("meeting.wav"),
    model="clarity-1",
    speaker=load_audio("speaker.wav"),
)
result.save("one-voice.wav")

generate

Python
async def generate(audio: Audio, *, model: str, speaker: Audio | None = None) -> EnhancedAudio
def generate_sync(audio: Audio, *, model: str, speaker: Audio | None = None) -> EnhancedAudio
ArgumentTypeDescription
audioAudioThe recording, from load_audio. Any WAV the API accepts: 16-, 24- or 32-bit integer or 32-bit float PCM, mono or stereo, 8 to 48 kHz, at most 300 seconds.
modelstrRequired. "clarity-1".
speakerAudio | NoneA 2 to 8 second sample of one voice, from load_audio. Given: only that voice is kept. Omitted: noise is removed and every voice is kept.

Returns an EnhancedAudio with the same duration as the input. The request is bounded by the client's timeout (default 60 seconds).

Every generate call on one client reuses the same kept-alive connection, so only the first request pays for connecting.

prewarm

Readies enhancement ahead of time. Enhancement capacity scales down when nobody uses it, so the first request after a quiet period can wait several seconds; prewarm sends the warm-up request, which starts enhancement in the background and returns at once, and it opens the connection the next request reuses. Call it as soon as you know audio is coming, for example when the user opens the recording screen.

await client.enhance.prewarm()
result = await client.enhance.generate(load_audio("call.wav"), model="clarity-1")

The warm-up is not billed and does not count against your rate limits. It never fails your code: a network error, a timeout, or a refused warm-up such as 401 for a bad API key is logged as a warning (console.warn in TypeScript, tracing in Rust, the log_handler in C++, package:logging in Dart), and the first request connects as usual and raises its own error.

Python
async def prewarm() -> None
def prewarm_sync() -> None

A connection that sits idle for 50 seconds is closed. prewarm warms the connection generate uses and prewarm_sync the one generate_sync uses. The LiveKit and Pipecat enhancement filters call it for you when they start.

Enhance live audio

stream opens a WebSocket, sends your audio in the background while you iterate, and yields enhanced mono 16-bit PCM at 24 kHz as it is produced. Iteration ends once the last input chunk has been enhanced; the total output matches the input's duration.

import asyncio

from kugelaudio import KugelAudio, load_audio_stream

client = KugelAudio(api_key="YOUR_API_KEY")

async def main():
    enhanced = bytearray()
    audio = load_audio_stream("call.wav")
    async for chunk in client.enhance.stream(audio, model="clarity-1"):
        enhanced += chunk  # mono PCM16 at 24 kHz, as it arrives

asyncio.run(main())

# stream_sync is the blocking form; it sends from a background thread
for chunk in client.enhance.stream_sync(load_audio_stream("call.wav"), model="clarity-1"):
    print(len(chunk), "bytes")

Add a speaker sample (speaker=load_audio("speaker.wav"), speaker: await loadAudio('speaker.wav'), .speaker(load_audio("speaker.wav")?), options.speaker = kugelaudio::load_audio("speaker.wav"), speaker: await loadAudioFile('speaker.wav')) to keep one voice.

Raw PCM input

Audio that is not a WAV file, such as a live capture, goes in as any iterable (or async iterable) of mono 16-bit little-endian PCM chunks, with its sample rate:

def pcm_chunks(path, chunk_bytes=3200):  # 0.1 s of mono PCM16 at 16 kHz
    with open(path, "rb") as f:
        while chunk := f.read(chunk_bytes):
            yield chunk

for chunk in client.enhance.stream_sync(
    pcm_chunks("call.pcm"), model="clarity-1", sample_rate=16000
):
    print(len(chunk), "bytes")

In Python the chunks are bytes (stream_sync takes only synchronous iterables); in TypeScript they are Uint8Array or ArrayBuffer; in Rust Vec<u8> items of a PcmInput (PcmInput::from_chunks takes chunks already in memory); in C++ the Bytes your PcmSource returns; in Dart List<int> events of a stream. Chunks of at most one second work best; longer ones are split before sending.

stream

Python
def stream(
    audio: AudioStream | Iterable[bytes] | AsyncIterable[bytes],
    *,
    model: str,
    sample_rate: int | None = None,
    speaker: Audio | None = None,
) -> AsyncIterator[bytes]

def stream_sync(
    audio: AudioStream | Iterable[bytes],
    *,
    model: str,
    sample_rate: int | None = None,
    speaker: Audio | None = None,
) -> Iterator[bytes]
ArgumentTypeDescription
audioAudioStream, or an iterable of bytesAn AudioStream from load_audio_stream, or raw mono PCM16 chunks. stream_sync does not take async iterables.
modelstrRequired. "clarity-1".
sample_rateint | NoneSample rate of raw chunks, 8000 to 48000. Required for raw chunks; taken from an AudioStream, and must match it if you pass both.
speakerAudio | NoneA 2 to 8 second sample of one voice, from load_audio.

Breaking out of the loop closes the connection and stops sending. With stream, wrap the iterator in contextlib.aclosing to make that cleanup immediate. The client's timeout bounds the wait for the stream to become ready.

Several audios on one connection

Each stream call opens its own WebSocket. To enhance one audio after another, for example one utterance at a time in a voice agent, open a session instead: it keeps one connection warm and sends each audio over it, so only the first pays for connecting. connect() opens it ahead of the first audio.

async with client.enhance.session() as session:  # connects here
    for path in ("first.wav", "second.wav"):
        async for chunk in session.stream(load_audio_stream(path), model="clarity-1"):
            play(chunk)

session.stream takes the same arguments as stream, and each call may use a different speaker or sample rate. Streams on one session run one after another; each is a request of its own for rate limits and billing. If the server closed the connection in the meantime (60 seconds without audio, one hour after it opened, or a restart), the next stream reconnects without an error. Breaking out of a stream closes the connection; the next stream opens a new one. The WebSocket reference lists the session limits.

Loading audio

The loaders read a WAV file for generate and for the speaker sample, or a 16-bit WAV as a chunked stream for stream. A loaded file is sent as-is and decoded by the server, so every supported WAV format works for generate.

load_audio

Python
def load_audio(source: str | os.PathLike | bytes) -> Audio

Loads a WAV file, from a path or its bytes, for generate and for speaker=. The returned Audio has:

FieldTypeDescription
databytesThe WAV file bytes.
filenamestrFile name sent with the upload ("audio.wav" for bytes).
durationfloat | NoneDuration in seconds when the header can be read locally, else None (for example float WAVs).

load_audio_stream

Python
def load_audio_stream(source: str | os.PathLike | bytes, *, chunk_seconds: float = 0.1) -> AudioStream

Loads a 16-bit PCM WAV for stream. Stereo and multi-channel audio is mixed down to mono, and the sample rate is read from the file. chunk_seconds (greater than 0, at most 1) sets the length of each chunk; chunks are sent as fast as the connection allows, and small ones let enhanced audio start coming back sooner.

The returned AudioStream has sample_rate (Hz) and duration (seconds), and iterating it yields the bytes chunks; iterating again starts from the beginning. For 24-bit or float WAVs, use generate instead or convert to 16-bit.

EnhancedAudio

What generate returns: mono 16-bit PCM at 24 kHz.

MemberTypeDescription
audiobytesRaw mono PCM16 samples, little-endian.
sample_rateint24000.
durationfloatDuration in seconds.
wavbytesThe audio as WAV file bytes.
save(path)methodWrites the audio to path as a WAV file.

Errors

Raised by the SDK itself, not the server:

ExceptionWhen
TypeErroraudio (for generate) or speaker is not an Audio, for example a path string. Wrap it in load_audio(...).
ValidationErrormodel is empty; stream got a path or WAV bytes (use load_audio_stream); raw chunks without sample_rate, or a sample_rate that does not match the AudioStream; a chunk that is not bytes; load_audio_stream got a WAV that is not 16-bit, or a chunk_seconds outside (0, 1]; an unreadable or empty file.

Raised from the server's answer, all subclasses of KugelAudioError with status_code, error_code, request_id and retry_after: ValidationError (the server rejected the input), RateLimitError, InsufficientCreditsError, AuthenticationError and KugelAudioConnectionError.

A stream refused while connecting reports both 402s with error_code INSUFFICIENT_CREDITS; on the free month it means the month is over.

What each server error means and what to do is in Limits and errors.

from kugelaudio import InsufficientCreditsError, RateLimitError

try:
    result = client.enhance.generate_sync(load_audio("call.wav"), model="clarity-1")
except RateLimitError as e:
    print("retry in", e.retry_after, "seconds")  # None for the concurrency limit
except InsufficientCreditsError as e:
    print(e.error_code, e.message)  # INSUFFICIENT_CREDITS or FREE_PERIOD_ENDED

Next steps