Duration: 3 hours Level: Intermediate Prerequisites: Workshop 1, Basic async/await knowledge
Workshop Overview#
In this workshop, you'll learn production-ready patterns for building robust AI-powered applications. We'll cover streaming, batch processing, resilience patterns, and performance optimization.
Learning Objectives#
By the end of this workshop, you will be able to:
- Implement streaming audio synthesis
- Process batches with concurrency control
- Build resilient systems with retries and circuit breakers
- Manage resources efficiently
- Monitor and observe your applications
Agenda#
| Time | Topic |
|---|---|
| 0:00 - 0:15 | Review and Setup |
| 0:15 - 0:45 | Part 1: Streaming |
| 0:45 - 1:15 | Part 2: Batch Processing |
| 1:15 - 1:30 | Break |
| 1:30 - 2:00 | Part 3: Resilience Patterns |
| 2:00 - 2:30 | Part 4: Resource Management |
| 2:30 - 2:50 | Part 5: Observability |
| 2:50 - 3:00 | Q&A and Wrap-up |
Review and Setup (15 minutes)#
Quick Review#
From Workshop 1, you should know:
- How to initialize providers
- Basic synthesis and generation
- Model discovery
- Basic error handling
Today's Setup#
import {
ElevenLabsProvider,
RunComfyProvider,
CivitaiProvider,
} from '@oshun/ai-providers';
// Initialize all providers
const elevenlabs = new ElevenLabsProvider({
apiKey: process.env.ELEVENLABS_API_KEY!,
maxRetries: 3,
});
const runcomfy = new RunComfyProvider({
apiKey: process.env.RUNCOMFY_API_KEY!,
maxConcurrentJobs: 5,
});
const civitai = new CivitaiProvider({
apiKey: process.env.CIVITAI_API_KEY,
});
Part 1: Streaming (30 minutes)#
Concept: Why Streaming?#
Traditional synthesis:
[Wait 5s for full audio] → [Play audio]
Streaming synthesis:
[Start playing immediately] → [Continue as chunks arrive]
Benefits:
- Lower perceived latency
- Better user experience
- Memory efficient for long content
Exercise 1.1: Basic Streaming#
// Start a streaming session
const session = await elevenlabs.startStreamingSession({
voiceId: 'EXAVITQu4vr4xnSDxMaL',
modelId: 'eleven_flash_v2_5', // Optimized for streaming
optimizeStreamingLatency: 4, // Maximum optimization
});
console.log(`Session started: ${session.sessionId}`);
// Collect audio chunks
const chunks: Buffer[] = [];
session.on('audio', (chunk: Buffer) => {
chunks.push(chunk);
console.log(`Received chunk: ${chunk.length} bytes`);
});
session.on('end', () => {
const totalAudio = Buffer.concat(chunks);
console.log(`Total audio: ${totalAudio.length} bytes`);
});
// Send text
await session.sendText('Hello! This is streaming synthesis.');
await session.sendText('Each sentence is processed separately.');
await session.flush();
await session.close();
Exercise 1.2: Interactive Streaming#
Build a conversation-like experience:
async function interactiveAssistant(
responses: string[]
): Promise<void> {
const session = await elevenlabs.startStreamingSession({
voiceId: 'EXAVITQu4vr4xnSDxMaL',
modelId: 'eleven_flash_v2_5',
optimizeStreamingLatency: 4,
});
for (const response of responses) {
console.log(`Speaking: "${response}"`);
// Send and wait for this response to complete
await session.sendText(response);
await session.flush();
// Simulate thinking time
await new Promise(r => setTimeout(r, 500));
}
await session.close();
}
// Usage
await interactiveAssistant([
'Welcome to our AI assistant.',
'How can I help you today?',
'I can answer questions about anything.',
]);
Exercise 1.3: Audio Buffer Class#
Implement a buffer that manages streaming audio:
class AudioBuffer {
private chunks: Buffer[] = [];
private totalBytes = 0;
addChunk(chunk: Buffer): void {
this.chunks.push(chunk);
this.totalBytes += chunk.length;
}
getBuffer(): Buffer {
return Buffer.concat(this.chunks);
}
getTotalBytes(): number {
return this.totalBytes;
}
// Estimate duration (44100Hz, 16-bit, mono)
getEstimatedDuration(): number {
return this.totalBytes / (44100 * 2);
}
clear(): void {
this.chunks = [];
this.totalBytes = 0;
}
}
// Use it with streaming
const buffer = new AudioBuffer();
session.on('audio', (chunk) => {
buffer.addChunk(chunk);
console.log(`Duration so far: ${buffer.getEstimatedDuration().toFixed(2)}s`);
});
Your task: Extend AudioBuffer to track chunks per second and estimate remaining duration based on expected total.
Part 2: Batch Processing (30 minutes)#
Concept: Concurrency Control#
Processing many items requires balance:
- Too few concurrent: Slow
- Too many concurrent: Rate limits, resource exhaustion
┌───────────────────────────────────────────────┐
│ Items: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] │
│ │
│ Concurrency = 3: │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ 1 │ │ 2 │ │ 3 │ ← Processing │
│ └─────┘ └─────┘ └─────┘ │
│ [4, 5, 6, 7, 8, 9, 10] ← Waiting │
└───────────────────────────────────────────────┘
Exercise 2.1: Simple Batch Processing#
async function processBatch<T, R>(
items: T[],
processor: (item: T) => Promise<R>,
concurrency: number
): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += concurrency) {
const batch = items.slice(i, i + concurrency);
const batchResults = await Promise.all(
batch.map(item => processor(item))
);
results.push(...batchResults);
console.log(`Processed ${results.length}/${items.length}`);
}
return results;
}
// Usage: Generate multiple images
const prompts = [
'A red dragon',
'A blue phoenix',
'A green griffin',
'A golden unicorn',
];
const results = await processBatch(
prompts,
async (prompt) => {
const job = await runcomfy.executeWorkflow({
workflowId: 'txt2img-sdxl-v1',
inputs: { prompt, width: 1024, height: 1024 },
});
return runcomfy.waitForCompletion(job.id);
},
2 // Process 2 at a time
);
Exercise 2.2: Rate-Limited Processing#
async function rateLimitedProcess<T, R>(
items: T[],
processor: (item: T) => Promise<R>,
requestsPerSecond: number
): Promise<R[]> {
const results: R[] = [];
const delayMs = 1000 / requestsPerSecond;
for (const item of items) {
const start = Date.now();
results.push(await processor(item));
// Maintain rate limit
const elapsed = Date.now() - start;
if (elapsed < delayMs) {
await new Promise(r => setTimeout(r, delayMs - elapsed));
}
}
return results;
}
Exercise 2.3: Batch with Progress and Cancellation#
async function advancedBatch<T, R>(
items: T[],
processor: (item: T) => Promise<R>,
options: {
concurrency: number;
signal?: AbortSignal;
onProgress?: (completed: number, total: number) => void;
}
): Promise<Array<{ item: T; result?: R; error?: Error }>> {
const results: Array<{ item: T; result?: R; error?: Error }> = [];
let completed = 0;
for (let i = 0; i < items.length; i += options.concurrency) {
// Check for cancellation
if (options.signal?.aborted) {
for (let j = i; j < items.length; j++) {
results.push({ item: items[j], error: new Error('Cancelled') });
}
break;
}
const batch = items.slice(i, i + options.concurrency);
const batchResults = await Promise.all(
batch.map(async (item) => {
try {
const result = await processor(item);
return { item, result };
} catch (error) {
return { item, error: error as Error };
}
})
);
results.push(...batchResults);
completed += batch.length;
options.onProgress?.(completed, items.length);
}
return results;
}
// Usage with AbortController
const controller = new AbortController();
// Cancel after 30 seconds
setTimeout(() => controller.abort(), 30000);
const results = await advancedBatch(
prompts,
processor,
{
concurrency: 3,
signal: controller.signal,
onProgress: (done, total) => {
console.log(`Progress: ${done}/${total} (${(done/total*100).toFixed(1)}%)`);
},
}
);
Your task: Add a timeout per item and handle partial failures gracefully.
Part 3: Resilience Patterns (30 minutes)#
Concept: Failure Modes#
AI APIs can fail in many ways:
- Transient: Network glitches, temporary overload
- Rate limits: Too many requests
- Permanent: Invalid input, authentication failure
Good systems handle all failure modes gracefully.
Exercise 3.1: Exponential Backoff#
async function retryWithBackoff<T>(
operation: () => Promise<T>,
options: {
maxRetries: number;
initialDelayMs: number;
maxDelayMs: number;
}
): Promise<T> {
let delay = options.initialDelayMs;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= options.maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
// Check if retryable
const retryable = (error as any).retryable !== false;
if (!retryable || attempt === options.maxRetries) {
throw lastError;
}
// Add jitter (0-25%)
const jitter = delay * Math.random() * 0.25;
const waitTime = Math.min(delay + jitter, options.maxDelayMs);
console.log(`Attempt ${attempt + 1} failed. Retrying in ${waitTime.toFixed(0)}ms...`);
await new Promise(r => setTimeout(r, waitTime));
delay *= 2; // Exponential
}
}
throw lastError!;
}
// Usage
const result = await retryWithBackoff(
() => elevenlabs.synthesize({ text: 'Hello', voiceId: '...' }),
{ maxRetries: 3, initialDelayMs: 1000, maxDelayMs: 30000 }
);
Exercise 3.2: Circuit Breaker#
class CircuitBreaker {
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
private failures = 0;
private lastFailure = 0;
constructor(
private readonly threshold: number = 5,
private readonly resetTimeout: number = 30000
) {}
async execute<T>(operation: () => Promise<T>): Promise<T> {
this.updateState();
if (this.state === 'OPEN') {
throw new Error('Circuit breaker is OPEN');
}
try {
const result = await operation();
this.recordSuccess();
return result;
} catch (error) {
this.recordFailure();
throw error;
}
}
private updateState(): void {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailure >= this.resetTimeout) {
this.state = 'HALF_OPEN';
}
}
}
private recordSuccess(): void {
this.failures = 0;
this.state = 'CLOSED';
}
private recordFailure(): void {
this.failures++;
this.lastFailure = Date.now();
if (this.failures >= this.threshold) {
this.state = 'OPEN';
}
}
getState(): string {
this.updateState();
return this.state;
}
}
// Usage
const breaker = new CircuitBreaker(5, 30000);
try {
const result = await breaker.execute(() =>
elevenlabs.synthesize({ text: 'Test', voiceId: '...' })
);
} catch (error) {
if (error.message === 'Circuit breaker is OPEN') {
console.log('Service is temporarily unavailable');
}
}
Exercise 3.3: Fallback Chain#
async function withFallback<T>(
primary: () => Promise<T>,
fallbacks: Array<() => Promise<T>>,
options?: { timeout?: number }
): Promise<T> {
const providers = [primary, ...fallbacks];
const errors: Error[] = [];
for (const provider of providers) {
try {
if (options?.timeout) {
const result = await Promise.race([
provider(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), options.timeout)
),
]);
return result;
}
return await provider();
} catch (error) {
errors.push(error as Error);
}
}
throw new Error(`All providers failed: ${errors.map(e => e.message).join(', ')}`);
}
// Usage: Multiple providers for redundancy
const audio = await withFallback(
() => provider1.synthesize({ text, voiceId }),
[
() => provider2.synthesize({ text, voiceId }),
() => localTTS.synthesize({ text }), // Local fallback
],
{ timeout: 10000 }
);
Break (15 minutes)#
Part 4: Resource Management (30 minutes)#
Concept: Connection Pools#
Creating connections is expensive. Pools reuse connections:
┌─────────────────────────────────────────┐
│ Connection Pool │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ 1 │ │ 2 │ │ 3 │ │ 4 │ │ 5 │ │
│ └─┬─┘ └─┬─┘ └─┬─┘ └───┘ └───┘ │
│ │ │ │ (available) │
│ ▼ ▼ ▼ │
│ Task Task Task (in use) │
└─────────────────────────────────────────┘
Exercise 4.1: Simple Pool#
class ConnectionPool<T extends { shutdown: () => Promise<void> }> {
private available: T[] = [];
private inUse = new Set<T>();
private waiting: Array<(conn: T) => void> = [];
constructor(
private factory: () => Promise<T>,
private maxSize: number
) {}
async acquire(): Promise<T> {
// Return available connection
if (this.available.length > 0) {
const conn = this.available.pop()!;
this.inUse.add(conn);
return conn;
}
// Create new if under limit
if (this.inUse.size < this.maxSize) {
const conn = await this.factory();
this.inUse.add(conn);
return conn;
}
// Wait for available
return new Promise(resolve => {
this.waiting.push(resolve);
});
}
release(conn: T): void {
this.inUse.delete(conn);
if (this.waiting.length > 0) {
const waiter = this.waiting.shift()!;
this.inUse.add(conn);
waiter(conn);
} else {
this.available.push(conn);
}
}
async shutdown(): Promise<void> {
const all = [...this.available, ...this.inUse];
await Promise.all(all.map(c => c.shutdown()));
this.available = [];
this.inUse.clear();
}
}
Exercise 4.2: Cleanup Manager#
class CleanupManager {
private resources: Array<{
name: string;
cleanup: () => Promise<void>;
}> = [];
register(name: string, cleanup: () => Promise<void>): void {
this.resources.push({ name, cleanup });
}
async cleanup(): Promise<void> {
// Cleanup in reverse order (LIFO)
const reversed = [...this.resources].reverse();
for (const { name, cleanup } of reversed) {
try {
await cleanup();
console.log(`Cleaned up: ${name}`);
} catch (error) {
console.error(`Failed to cleanup ${name}:`, error);
}
}
this.resources = [];
}
}
// Usage
const manager = new CleanupManager();
manager.register('ElevenLabs', () => elevenlabs.shutdown());
manager.register('RunComfy', () => runcomfy.shutdown());
// Cleanup on exit
process.on('SIGINT', async () => {
console.log('Shutting down...');
await manager.cleanup();
process.exit(0);
});
Part 5: Observability (20 minutes)#
Concept: Metrics and Monitoring#
Production systems need visibility:
- How many requests?
- How fast?
- What's failing?
Exercise 5.1: Metrics Collector#
class MetricsCollector {
private requests: Array<{
timestamp: number;
endpoint: string;
success: boolean;
latencyMs: number;
}> = [];
record(endpoint: string, success: boolean, latencyMs: number): void {
this.requests.push({
timestamp: Date.now(),
endpoint,
success,
latencyMs,
});
}
getStats(windowMs = 60000): {
total: number;
success: number;
failure: number;
avgLatency: number;
p95Latency: number;
} {
const cutoff = Date.now() - windowMs;
const recent = this.requests.filter(r => r.timestamp >= cutoff);
if (recent.length === 0) {
return { total: 0, success: 0, failure: 0, avgLatency: 0, p95Latency: 0 };
}
const latencies = recent.map(r => r.latencyMs).sort((a, b) => a - b);
return {
total: recent.length,
success: recent.filter(r => r.success).length,
failure: recent.filter(r => !r.success).length,
avgLatency: latencies.reduce((a, b) => a + b, 0) / latencies.length,
p95Latency: latencies[Math.floor(latencies.length * 0.95)],
};
}
}
// Usage
const metrics = new MetricsCollector();
// Wrap operations
async function trackedSynthesize(text: string, voiceId: string) {
const start = Date.now();
try {
const result = await elevenlabs.synthesize({ text, voiceId });
metrics.record('synthesize', true, Date.now() - start);
return result;
} catch (error) {
metrics.record('synthesize', false, Date.now() - start);
throw error;
}
}
// Report stats
setInterval(() => {
const stats = metrics.getStats();
console.log(`Stats: ${stats.total} requests, ${stats.avgLatency.toFixed(0)}ms avg`);
}, 10000);
Q&A and Wrap-up (10 minutes)#
Key Takeaways#
- Streaming: Lower latency, better UX
- Batch Processing: Control concurrency, handle failures
- Resilience: Retry, circuit breakers, fallbacks
- Resources: Pool connections, clean up properly
- Observability: Metrics enable improvement
Next Steps#
- Complete intermediate exercises
- Implement patterns in your projects
- Attend Workshop 3: Advanced Architecture
Homework#
Build a "Voice Article Reader" that:
- Takes a URL or text
- Breaks into paragraphs
- Synthesizes each paragraph with streaming
- Handles failures gracefully
- Reports progress and statistics