use inference text in demo, clean up image generation

This commit is contained in:
Moishe Lettvin
2024-01-04 17:26:13 -05:00
parent c68703749b
commit fcd9a248d9
5 changed files with 390 additions and 15 deletions

4
.gitignore vendored
View File

@@ -22,4 +22,6 @@ share/python-wheels/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
MANIFEST MANIFEST
.DS_Store

View File

@@ -1,6 +1,8 @@
import logging import logging
from abc import abstractmethod from abc import abstractmethod
from collections.abc import AsyncGenerator
from dataclasses import dataclass from dataclasses import dataclass
from typing import Generator from typing import Generator
from PIL import Image from PIL import Image
@@ -13,18 +15,17 @@ class AIService:
def close(self): def close(self):
pass pass
class LLMService(AIService): class LLMService(AIService):
# Generate a set of responses to a prompt. Yields a list of responses. # Generate a set of responses to a prompt. Yields a list of responses.
@abstractmethod @abstractmethod
def run_llm_async( async def run_llm_async(
self, messages self, messages
) -> Generator[str, None, None]: ) -> AsyncGenerator[str, None, None]:
pass pass
# Generate a responses to a prompt. Returns the response # Generate a responses to a prompt. Returns the response
@abstractmethod @abstractmethod
def run_llm( async def run_llm(
self, messages self, messages
) -> str or None: ) -> str or None:
pass pass
@@ -38,14 +39,14 @@ class TTSService(AIService):
# Converts the sentence to audio. Yields a list of audio frames that can # Converts the sentence to audio. Yields a list of audio frames that can
# be sent to the microphone device # be sent to the microphone device
@abstractmethod @abstractmethod
def run_tts(self, sentence) -> Generator[bytes, None, None]: async def run_tts(self, sentence) -> AsyncGenerator[bytes, None, None]:
pass pass
class ImageGenService(AIService): class ImageGenService(AIService):
# Renders the image. Returns an Image object. # Renders the image. Returns an Image object.
@abstractmethod @abstractmethod
def run_image_gen(self, sentence) -> tuple[str, Image.Image]: async def run_image_gen(self, sentence) -> tuple[str, Image.Image]:
pass pass

View File

@@ -1,11 +1,13 @@
import json import aiohttp
import asyncio
import io import io
import json
from openai import AzureOpenAI from openai import AzureOpenAI
import os import os
import requests import requests
from typing import Generator from collections.abc import AsyncGenerator
from dailyai.services.ai_services import LLMService, TTSService, ImageGenService from dailyai.services.ai_services import LLMService, TTSService, ImageGenService
from PIL import Image from PIL import Image
@@ -23,7 +25,7 @@ class AzureTTSService(TTSService):
self.speech_config = SpeechConfig(subscription=speech_key, region=speech_region) self.speech_config = SpeechConfig(subscription=speech_key, region=speech_region)
self.speech_synthesizer = SpeechSynthesizer(speech_config=self.speech_config, audio_config=None) self.speech_synthesizer = SpeechSynthesizer(speech_config=self.speech_config, audio_config=None)
def run_tts(self, sentence) -> Generator[bytes, None, None]: async def run_tts(self, sentence) -> AsyncGenerator[bytes, None, None]:
self.logger.info("Running azure tts") self.logger.info("Running azure tts")
ssml = "<speak version='1.0' xml:lang='en-US' xmlns='http://www.w3.org/2001/10/synthesis' " \ ssml = "<speak version='1.0' xml:lang='en-US' xmlns='http://www.w3.org/2001/10/synthesis' " \
"xmlns:mstts='http://www.w3.org/2001/mstts'>" \ "xmlns:mstts='http://www.w3.org/2001/mstts'>" \
@@ -33,7 +35,7 @@ class AzureTTSService(TTSService):
"<prosody rate='1.05'>" \ "<prosody rate='1.05'>" \
f"{sentence}" \ f"{sentence}" \
"</prosody></mstts:express-as></voice></speak> " "</prosody></mstts:express-as></voice></speak> "
result = self.speech_synthesizer.speak_ssml(ssml) result = await asyncio.to_thread(self.speech_synthesizer.speak_ssml, (ssml))
self.logger.info("Got azure tts result") self.logger.info("Got azure tts result")
if result.reason == ResultReason.SynthesizingAudioCompleted: if result.reason == ResultReason.SynthesizingAudioCompleted:
self.logger.info("Returning result") self.logger.info("Returning result")
@@ -65,7 +67,7 @@ class AzureLLMService(LLMService):
model=self.model, model=self.model,
) )
def run_llm_async(self, messages) -> Generator[str, None, None]: async def run_llm_async(self, messages) -> AsyncGenerator[str, None, None]:
messages_for_log = json.dumps(messages) messages_for_log = json.dumps(messages)
self.logger.debug(f"Generating chat via azure: {messages_for_log}") self.logger.debug(f"Generating chat via azure: {messages_for_log}")
@@ -78,7 +80,7 @@ class AzureLLMService(LLMService):
if chunk.choices[0].delta.content: if chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content yield chunk.choices[0].delta.content
def run_llm(self, messages) -> str | None: async def run_llm(self, messages) -> str | None:
messages_for_log = json.dumps(messages) messages_for_log = json.dumps(messages)
self.logger.debug(f"Generating chat via azure: {messages_for_log}") self.logger.debug(f"Generating chat via azure: {messages_for_log}")
@@ -88,6 +90,49 @@ class AzureLLMService(LLMService):
else: else:
return None return None
class AzureImageGenServiceREST(ImageGenService):
def __init__(self, api_key=None, azure_endpoint=None, api_version=None, model=None):
super().__init__()
self.api_key = api_key or os.getenv("AZURE_DALLE_KEY")
self.azure_endpoint = azure_endpoint or os.getenv("AZURE_DALLE_ENDPOINT")
self.api_version = api_version or "2023-06-01-preview"
self.model = model or os.getenv("AZURE_DALLE_DEPLOYMENT_ID")
async def run_image_gen(self, sentence, size) -> tuple[str, Image.Image]:
# TODO hoist the session to app-level
async with aiohttp.ClientSession() as session:
url = f"{self.azure_endpoint}openai/images/generations:submit?api-version={self.api_version}"
headers= { "api-key": self.api_key, "Content-Type": "application/json" }
body = {
# Enter your prompt text here
"prompt": sentence,
"size": size,
"n": 1,
}
async with session.post(url, headers=headers, json=body) as submission:
operation_location = submission.headers['operation-location']
status = ""
attempts_left = 120
while status != "succeeded":
attempts_left -= 1
if attempts_left == 0:
raise Exception("Image generation timed out")
await asyncio.sleep(1)
response = await session.get(operation_location, headers=headers)
json_response = await response.json()
status = json_response["status"]
image_url = json_response["result"]["data"][0]["url"]
# Load the image from the url
async with session.get(image_url) as response:
image_stream = io.BytesIO(await response.content.read())
image = Image.open(image_stream)
return (image_url, image.tobytes())
class AzureImageGenService(ImageGenService): class AzureImageGenService(ImageGenService):
@@ -96,7 +141,7 @@ class AzureImageGenService(ImageGenService):
api_key = api_key or os.getenv("AZURE_DALLE_KEY") api_key = api_key or os.getenv("AZURE_DALLE_KEY")
azure_endpoint = azure_endpoint or os.getenv("AZURE_DALLE_ENDPOINT") azure_endpoint = azure_endpoint or os.getenv("AZURE_DALLE_ENDPOINT")
api_version = api_version or "2023-12-01-preview" api_version = api_version or "2023-06-01-preview"
self.model = model or os.getenv("AZURE_DALLE_DEPLOYMENT_ID") self.model = model or os.getenv("AZURE_DALLE_DEPLOYMENT_ID")
self.client = AzureOpenAI( self.client = AzureOpenAI(
@@ -105,7 +150,7 @@ class AzureImageGenService(ImageGenService):
api_version=api_version, api_version=api_version,
) )
def run_image_gen(self, sentence) -> tuple[str, Image.Image]: async def run_image_gen(self, sentence) -> tuple[str, Image.Image]:
self.logger.info("Generating azure image", sentence) self.logger.info("Generating azure image", sentence)
image = self.client.images.generate( image = self.client.images.generate(

View File

@@ -0,0 +1,267 @@
import inspect
import logging
import time
import types
from functools import partial
from queue import Queue, Empty
from dailyai.output_queue import OutputQueueFrame, FrameType
from threading import Thread, Event, Timer
from daily import (
EventHandler,
CallClient,
Daily,
VirtualCameraDevice,
VirtualMicrophoneDevice,
VirtualSpeakerDevice,
)
class DailyTransportService(EventHandler):
def __init__(
self,
room_url: str,
token: str,
bot_name: str,
duration: float = 10,
):
super().__init__()
self.bot_name: str = bot_name
self.room_url: str = room_url
self.token: str = token
self.duration: float = duration
self.expiration = time.time() + duration * 60
self.output_queue = Queue()
self.is_interrupted = Event()
self.stop_threads = Event()
self.story_started = False
self.logger: logging.Logger = logging.getLogger("dailyai")
self.event_handlers = {}
def monkeypatch(self, event_name, *args):
for handler in self.event_handlers[event_name]:
handler(*args)
def add_event_handler(self, event_name: str, handler):
if not event_name.startswith("on_"):
raise Exception(f"Event handler {event_name} must start with 'on_'")
methods = inspect.getmembers(self, predicate=inspect.ismethod)
if event_name not in [method[0] for method in methods]:
raise Exception(f"Event handler {event_name} not found")
if not event_name in self.event_handlers:
self.event_handlers[event_name] = [getattr(self, event_name), types.MethodType(handler, self)]
setattr(self, event_name, partial(self.monkeypatch, event_name))
else:
self.event_handlers[event_name].append(types.MethodType(handler, self))
def configure_daily(self):
Daily.init()
self.client = CallClient(event_handler=self)
if self.mic_enabled:
self.mic: VirtualMicrophoneDevice = Daily.create_microphone_device(
"mic", sample_rate=self.mic_sample_rate, channels=1
)
if self.camera_enabled:
self.camera: VirtualCameraDevice = Daily.create_camera_device(
"camera", width=self.camera_width, height=self.camera_height, color_format="RGB"
)
self.speaker: VirtualSpeakerDevice = Daily.create_speaker_device(
"speaker", sample_rate=16000, channels=1
)
Daily.select_speaker_device("speaker")
self.client.set_user_name(self.bot_name)
self.client.join(self.room_url, self.token, completion=self.call_joined)
self.client.update_inputs(
{
"camera": {
"isEnabled": True,
"settings": {
"deviceId": "camera",
},
},
"microphone": {
"isEnabled": True,
"settings": {
"deviceId": "mic",
"customConstraints": {
"autoGainControl": {"exact": False},
"echoCancellation": {"exact": False},
"noiseSuppression": {"exact": False},
},
},
},
}
)
self.client.update_publishing(
{
"camera": {
"sendSettings": {
"maxQuality": "low",
"encodings": {
"low": {
"maxBitrate": 250000,
"scaleResolutionDownBy": 1.333,
"maxFramerate": 8,
}
},
}
}
}
)
self.my_participant_id = self.client.participants()["local"]["id"]
def run(self) -> None:
self.configure_daily()
self.running_thread = Thread(target=self.run_daily, daemon=True)
self.running_thread.start()
def run_daily(self):
# TODO: this loop could, I think, be replaced with a timer and an event
self.participant_left = False
try:
participant_count: int = len(self.client.participants())
self.logger.info(f"{participant_count} participants in room")
while time.time() < self.expiration and not self.participant_left:
# all handling of incoming transcriptions happens in on_transcription_message
time.sleep(1)
except Exception as e:
self.logger.error(f"Exception {e}")
finally:
self.client.leave()
def stop(self):
self.stop_threads.set()
self.camera_thread.join()
self.output_queue.put(OutputQueueFrame(FrameType.END_STREAM, None))
self.frame_consumer_thread.join()
self.client.leave()
def call_joined(self, join_data, client_error):
self.logger.info(f"Call_joined: {join_data}, {client_error}")
self.image: bytes | None = None
self.camera_thread = Thread(target=self.run_camera, daemon=True)
self.camera_thread.start()
self.logger.info("Starting frame consumer thread")
self.frame_consumer_thread = Thread(target=self.frame_consumer, daemon=True)
self.frame_consumer_thread.start()
if self.token:
self.client.start_transcription(
{
"language": "en",
"tier": "nova",
"model": "2-conversationalai",
"profanity_filter": True,
"redact": False,
"extra": {
"endpointing": True,
"punctuate": False,
},
}
)
def on_participant_joined(self, participant):
pass
def on_participant_left(self, participant, reason):
pass
def on_app_message(self, message, sender):
pass
def on_transcription_message(self, message):
with self.tracer.start_as_current_span(
"on_transcription_message", context=self.ctx
):
if message["session_id"] != self.my_participant_id:
self.handle_transcription_fragment(message["text"])
def on_transcription_stopped(self, stopped_by, stopped_by_error):
self.logger.info(f"Transcription stopped {stopped_by}, {stopped_by_error}")
def on_transcription_error(self, message):
self.logger.error(f"Transcription error {message}")
def on_transcription_started(self, status):
self.logger.info(f"Transcription started {status}")
def set_image(self, image: bytes):
self.image: bytes | None = image
def run_camera(self):
try:
while not self.stop_threads.is_set():
if self.image:
self.camera.write_frame(self.image)
time.sleep(1.0 / 8.0) # 8 fps
except Exception as e:
self.logger.error(f"Exception {e} in camera thread.")
def frame_consumer(self):
self.logger.info("🎬 Starting frame consumer thread")
b = bytearray()
smallest_write_size = 3200
all_audio_frames = bytearray()
while True:
try:
frame: OutputQueueFrame = self.output_queue.get()
if frame.frame_type == FrameType.END_STREAM:
self.logger.info("Stopping frame consumer thread")
return
# if interrupted, we just pull frames off the queue and discard them
if not self.is_interrupted.is_set():
if frame:
if frame.frame_type == FrameType.AUDIO_FRAME:
chunk = frame.frame_data
all_audio_frames.extend(chunk)
b.extend(chunk)
l = len(b) - (len(b) % smallest_write_size)
if l:
self.mic.write_frames(bytes(b[:l]))
b = b[l:]
elif frame.frame_type == FrameType.IMAGE_FRAME:
self.set_image(frame.frame_data)
elif len(b):
self.mic.write_frames(bytes(b))
b = bytearray()
else:
if self.interrupt_time:
self.logger.info(
f"Lag to stop stream after interruption {time.perf_counter() - self.interrupt_time}"
)
self.interrupt_time = None
if frame.frame_type == FrameType.START_STREAM:
self.is_interrupted.clear()
self.output_queue.task_done()
except Empty:
try:
if len(b):
self.mic.write_frames(bytes(b))
except Exception as e:
self.logger.error(f"Exception in frame_consumer: {e}, {len(b)}")
b = bytearray()

View File

@@ -0,0 +1,60 @@
import asyncio
from dailyai.output_queue import OutputQueueFrame, FrameType
from dailyai.services.azure_ai_services import AzureLLMService, AzureTTSService, AzureImageGenServiceREST
from dailyai.services.daily_transport_service import DailyTransportService
async def main(room_url, token):
class Sample05Transport(DailyTransportService):
def on_participant_joined(self, participant):
super().on_participant_joined(participant)
meeting_duration_minutes = 4
transport = Sample05Transport(
room_url,
token,
"Simple Bot",
meeting_duration_minutes,
)
transport.mic_enabled = True
transport.camera_enabled = True
transport.mic_sample_rate = 16000
transport.camera_width = 1024
transport.camera_height = 1024
llm = AzureLLMService()
tts = AzureTTSService()
dalle = AzureImageGenServiceREST()
inference_text_process = llm.run_llm(
[
{
"role": "system",
"content": f"Describe a nature photograph suitable for use in a calendar, for the month of January. Include only the image description with no preamble."
}
]
)
try:
transport.run()
inference_text = await inference_text_process
tts_iterator = tts.run_tts(inference_text)
(image, audio) = await asyncio.gather(
*[dalle.run_image_gen(inference_text, "1024x1024"), anext(tts_iterator)]
)
transport.output_queue.put(OutputQueueFrame(FrameType.IMAGE_FRAME, image[1]))
transport.output_queue.put(OutputQueueFrame(FrameType.AUDIO_FRAME, audio))
async for audio in tts_iterator:
transport.output_queue.put(
OutputQueueFrame(FrameType.AUDIO_FRAME, audio)
)
await asyncio.sleep(meeting_duration_minutes * 60)
finally:
transport.stop()
print("Done")
if __name__=="__main__":
asyncio.run(main("https://moishe.daily.co/Lettvins", None))