Skip to content

Commit 2f03b90

Browse files
committed
Refactor audio stream workers and enhance configuration management
- Introduced a base class `BaseStreamWorker` to standardize audio stream worker implementations, improving code reuse and maintainability. - Updated `DeepgramStreamWorker` and `ParakeetStreamWorker` to inherit from `BaseStreamWorker`, streamlining their initialization and configuration validation processes. - Enhanced error handling and logging for Redis connections and consumer initialization in the worker classes. - Refactored memory processing logic to improve conversation text extraction and speaker identification. - Added unit tests for audio stream workers to ensure reliability and correct functionality across various scenarios.
1 parent a6eb773 commit 2f03b90

10 files changed

Lines changed: 1359 additions & 844 deletions

File tree

backends/advanced/src/advanced_omi_backend/controllers/conversation_controller.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -447,9 +447,6 @@ async def reprocess_memory(conversation_id: str, transcript_version_id: str, use
447447
from advanced_omi_backend.models.job import JobPriority
448448

449449
job = enqueue_memory_processing(
450-
client_id=conversation_model.client_id,
451-
user_id=str(user.user_id),
452-
user_email=user.email,
453450
conversation_id=conversation_id,
454451
priority=JobPriority.NORMAL
455452
)

backends/advanced/src/advanced_omi_backend/routers/modules/annotation_routes.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,6 @@ async def create_annotation(
7373

7474
# Trigger memory reprocessing
7575
enqueue_memory_processing(
76-
client_id=conversation.client_id,
77-
user_id=str(current_user.id),
78-
user_email=current_user.email,
7976
conversation_id=conversation.conversation_id,
8077
priority=JobPriority.NORMAL
8178
)

backends/advanced/src/advanced_omi_backend/workers/audio_stream_deepgram_worker.py

Lines changed: 24 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -5,76 +5,38 @@
55
Starts a consumer that reads from audio:stream:deepgram and transcribes audio.
66
"""
77

8-
import asyncio
9-
import logging
108
import os
11-
import signal
12-
import sys
13-
14-
import redis.asyncio as redis
159

1610
from advanced_omi_backend.services.transcription.deepgram import DeepgramStreamConsumer
11+
from advanced_omi_backend.workers.base_audio_worker import BaseStreamWorker
1712

18-
logging.basicConfig(
19-
level=logging.INFO,
20-
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
21-
)
22-
23-
logger = logging.getLogger(__name__)
24-
25-
26-
async def main():
27-
"""Main worker entry point."""
28-
logger.info("🚀 Starting Deepgram audio stream worker")
29-
30-
# Check that config.yml has Deepgram configured
31-
# The registry provider will load configuration from config.yml
32-
api_key = os.getenv("DEEPGRAM_API_KEY")
33-
if not api_key:
34-
logger.warning("DEEPGRAM_API_KEY environment variable not set")
35-
logger.warning("Ensure config.yml has a default 'stt' model configured for Deepgram")
36-
logger.warning("Audio transcription will use alternative providers if configured in config.yml")
37-
38-
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
39-
40-
# Create Redis client
41-
redis_client = await redis.from_url(
42-
redis_url,
43-
encoding="utf-8",
44-
decode_responses=False
45-
)
46-
logger.info("Connected to Redis")
47-
48-
# Create consumer with balanced buffer size
49-
# 20 chunks = ~5 seconds of audio
50-
# Balance between transcription accuracy and latency
51-
# Consumer uses registry-driven provider from config.yml
52-
consumer = DeepgramStreamConsumer(
53-
redis_client=redis_client,
54-
buffer_chunks=20 # 5 seconds - good context without excessive delay
55-
)
56-
57-
# Setup signal handlers for graceful shutdown
58-
def signal_handler(signum, frame):
59-
logger.info(f"Received signal {signum}, shutting down...")
60-
asyncio.create_task(consumer.stop())
6113

62-
signal.signal(signal.SIGINT, signal_handler)
63-
signal.signal(signal.SIGTERM, signal_handler)
14+
class DeepgramStreamWorker(BaseStreamWorker):
15+
"""Deepgram audio stream worker implementation."""
6416

65-
try:
66-
logger.info("✅ Deepgram worker ready")
17+
def __init__(self):
18+
super().__init__(service_name="Deepgram audio stream worker")
6719

68-
# This blocks until consumer is stopped
69-
await consumer.start_consuming()
20+
def validate_config(self):
21+
"""Check that config.yml has Deepgram configured."""
22+
# The registry provider will load configuration from config.yml
23+
api_key = os.getenv("DEEPGRAM_API_KEY")
24+
if not api_key:
25+
self.logger.warning("DEEPGRAM_API_KEY environment variable not set")
26+
self.logger.warning("Ensure config.yml has a default 'stt' model configured for Deepgram")
27+
self.logger.warning("Audio transcription will use alternative providers if configured in config.yml")
7028

71-
except Exception as e:
72-
logger.error(f"Worker error: {e}", exc_info=True)
73-
sys.exit(1)
74-
finally:
75-
await redis_client.aclose()
76-
logger.info("👋 Deepgram worker stopped")
29+
def get_consumer(self, redis_client):
30+
"""Create Deepgram consumer with balanced buffer size."""
31+
# Create consumer with balanced buffer size
32+
# 20 chunks = ~5 seconds of audio
33+
# Balance between transcription accuracy and latency
34+
# Consumer uses registry-driven provider from config.yml
35+
return DeepgramStreamConsumer(
36+
redis_client=redis_client,
37+
buffer_chunks=20 # 5 seconds - good context without excessive delay
38+
)
7739

7840

7941
if __name__ == "__main__":
80-
asyncio.run(main())
42+
DeepgramStreamWorker.start()

backends/advanced/src/advanced_omi_backend/workers/audio_stream_parakeet_worker.py

Lines changed: 28 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -5,91 +5,39 @@
55
Starts a consumer that reads from audio:stream:* and transcribes audio using Parakeet.
66
"""
77

8-
import asyncio
9-
import logging
108
import os
11-
import signal
12-
import sys
13-
14-
import redis.asyncio as redis
159

1610
from advanced_omi_backend.services.transcription.parakeet_stream_consumer import ParakeetStreamConsumer
17-
18-
logging.basicConfig(
19-
level=logging.INFO,
20-
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
21-
)
22-
23-
logger = logging.getLogger(__name__)
24-
25-
26-
async def main():
27-
"""Main worker entry point."""
28-
logger.info("🚀 Starting Parakeet audio stream worker")
29-
30-
# Check that config.yml has Parakeet configured
31-
# The registry provider will load configuration from config.yml
32-
service_url = os.getenv("PARAKEET_ASR_URL")
33-
if not service_url:
34-
logger.warning("PARAKEET_ASR_URL environment variable not set")
35-
logger.warning("Ensure config.yml has a default 'stt' model configured for Parakeet")
36-
logger.warning("Audio transcription will use alternative providers if configured in config.yml")
37-
38-
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
39-
40-
# Create Redis client
41-
redis_client = await redis.from_url(
42-
redis_url,
43-
encoding="utf-8",
44-
decode_responses=False
45-
)
46-
logger.info("Connected to Redis")
47-
48-
# Create consumer with balanced buffer size
49-
# 20 chunks = ~5 seconds of audio
50-
# Balance between transcription accuracy and latency
51-
# Consumer uses registry-driven provider from config.yml
52-
consumer = ParakeetStreamConsumer(
53-
redis_client=redis_client,
54-
buffer_chunks=20 # 5 seconds - good context without excessive delay
55-
)
56-
57-
# Setup signal handlers for graceful shutdown
58-
shutdown_event = asyncio.Event()
59-
60-
def signal_handler(signum, _frame):
61-
logger.info(f"Received signal {signum}, shutting down...")
62-
shutdown_event.set()
63-
64-
signal.signal(signal.SIGINT, signal_handler)
65-
signal.signal(signal.SIGTERM, signal_handler)
66-
67-
try:
68-
logger.info("✅ Parakeet worker ready")
69-
70-
# This blocks until consumer is stopped or shutdown signaled
71-
consume_task = asyncio.create_task(consumer.start_consuming())
72-
shutdown_task = asyncio.create_task(shutdown_event.wait())
73-
74-
done, pending = await asyncio.wait(
75-
[consume_task, shutdown_task],
76-
return_when=asyncio.FIRST_COMPLETED
11+
from advanced_omi_backend.workers.base_audio_worker import BaseStreamWorker
12+
13+
14+
class ParakeetStreamWorker(BaseStreamWorker):
15+
"""Parakeet audio stream worker implementation."""
16+
17+
def __init__(self):
18+
super().__init__(service_name="Parakeet audio stream worker")
19+
20+
def validate_config(self):
21+
"""Check that config.yml has Parakeet configured."""
22+
# The registry provider will load configuration from config.yml
23+
service_url = os.getenv("PARAKEET_ASR_URL")
24+
if not service_url:
25+
self.logger.warning("PARAKEET_ASR_URL environment variable not set")
26+
self.logger.warning("Ensure config.yml has a default 'stt' model configured for Parakeet")
27+
self.logger.warning("Audio transcription will use alternative providers if configured in config.yml")
28+
29+
def get_consumer(self, redis_client):
30+
"""Create Parakeet consumer with balanced buffer size."""
31+
# Create consumer with balanced buffer size
32+
# 20 chunks = ~5 seconds of audio
33+
# Balance between transcription accuracy and latency
34+
# Consumer uses registry-driven provider from config.yml
35+
return ParakeetStreamConsumer(
36+
redis_client=redis_client,
37+
buffer_chunks=20 # 5 seconds - good context without excessive delay
7738
)
7839

79-
# Cancel pending tasks
80-
for task in pending:
81-
task.cancel()
82-
83-
await consumer.stop()
84-
85-
except Exception as e:
86-
logger.error(f"Worker error: {e}", exc_info=True)
87-
sys.exit(1)
88-
finally:
89-
await redis_client.aclose()
90-
logger.info("👋 Parakeet worker stopped")
91-
9240

9341
if __name__ == "__main__":
94-
asyncio.run(main())
42+
ParakeetStreamWorker.start()
9543

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""
2+
Base audio stream worker.
3+
4+
Provides a template for stream workers with consistent Redis connection,
5+
signal handling, and error management.
6+
"""
7+
8+
import asyncio
9+
import logging
10+
import os
11+
import signal
12+
import sys
13+
from abc import ABC, abstractmethod
14+
15+
import redis.asyncio as redis
16+
17+
# Configure basic logging if not already configured
18+
if not logging.getLogger().handlers:
19+
logging.basicConfig(
20+
level=logging.INFO,
21+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
22+
)
23+
24+
class BaseStreamWorker(ABC):
25+
"""
26+
Base class for audio stream workers using the Template Method pattern.
27+
28+
Subclasses must implement:
29+
- validate_config(): Check environment/config requirements
30+
- get_consumer(redis_client): Return the specific consumer instance
31+
"""
32+
33+
def __init__(self, service_name: str):
34+
self.service_name = service_name
35+
self.logger = logging.getLogger(self.__class__.__name__)
36+
self.redis_client = None
37+
self.consumer = None
38+
39+
@abstractmethod
40+
def validate_config(self):
41+
"""
42+
Check required environment variables or configuration.
43+
Should log warnings/errors if configuration is missing.
44+
"""
45+
pass
46+
47+
@abstractmethod
48+
def get_consumer(self, redis_client):
49+
"""
50+
Create and return the consumer instance.
51+
52+
Args:
53+
redis_client: Initialized Redis client
54+
55+
Returns:
56+
An instance complying with the BaseAudioStreamConsumer interface
57+
"""
58+
pass
59+
60+
async def run(self):
61+
"""Main execution loop."""
62+
self.logger.info(f"🚀 Starting {self.service_name}")
63+
64+
self.validate_config()
65+
66+
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
67+
68+
try:
69+
self.redis_client = await redis.from_url(
70+
redis_url,
71+
encoding="utf-8",
72+
decode_responses=False
73+
)
74+
self.logger.info("Connected to Redis")
75+
except Exception as e:
76+
self.logger.error(f"Failed to connect to Redis: {e}")
77+
sys.exit(1)
78+
79+
try:
80+
self.consumer = self.get_consumer(self.redis_client)
81+
except Exception as e:
82+
self.logger.error(f"Failed to initialize consumer: {e}")
83+
await self.redis_client.aclose()
84+
sys.exit(1)
85+
86+
# Setup graceful shutdown
87+
loop = asyncio.get_running_loop()
88+
stop_event = asyncio.Event()
89+
90+
def signal_handler():
91+
self.logger.info("Received stop signal, shutting down...")
92+
stop_event.set()
93+
94+
# Register signal handlers
95+
for sig in (signal.SIGINT, signal.SIGTERM):
96+
try:
97+
loop.add_signal_handler(sig, signal_handler)
98+
except NotImplementedError:
99+
# Fallback for environments where add_signal_handler is not supported
100+
# (e.g. some Windows environments or custom loops)
101+
self.logger.warning(f"Could not add signal handler for {sig}")
102+
103+
try:
104+
self.logger.info(f"✅ {self.service_name} ready")
105+
106+
# Run consumer as a task
107+
consumer_task = asyncio.create_task(self.consumer.start_consuming())
108+
stop_wait_task = asyncio.create_task(stop_event.wait())
109+
110+
# Wait for either the consumer to finish (error/done) or stop signal
111+
done, pending = await asyncio.wait(
112+
[consumer_task, stop_wait_task],
113+
return_when=asyncio.FIRST_COMPLETED
114+
)
115+
116+
# Check if consumer failed
117+
if consumer_task in done:
118+
try:
119+
await consumer_task
120+
except asyncio.CancelledError:
121+
pass
122+
except Exception as e:
123+
self.logger.error(f"Consumer task failed: {e}", exc_info=True)
124+
# We continue to cleanup
125+
126+
# Trigger stop on consumer
127+
self.logger.info("Stopping consumer...")
128+
await self.consumer.stop()
129+
130+
# Ensure consumer task finishes if it was running
131+
if consumer_task in pending:
132+
try:
133+
await consumer_task
134+
except asyncio.CancelledError:
135+
pass
136+
except Exception as e:
137+
# Ignore expected errors during shutdown if any
138+
self.logger.debug(f"Consumer shutdown exception: {e}")
139+
140+
except Exception as e:
141+
self.logger.error(f"Worker runtime error: {e}", exc_info=True)
142+
sys.exit(1)
143+
finally:
144+
if self.redis_client:
145+
await self.redis_client.aclose()
146+
self.logger.info(f"👋 {self.service_name} stopped")
147+
148+
@classmethod
149+
def start(cls):
150+
"""Entry point for script execution."""
151+
instance = cls()
152+
try:
153+
asyncio.run(instance.run())
154+
except KeyboardInterrupt:
155+
# Handle keyboard interrupt outside the loop if it propagates
156+
pass

0 commit comments

Comments
 (0)