TL;博士✨

在這個簡單易懂的教學中,您將學習如何使用 LLAMA-3 AI 模型建立您自己的語音助理 Siri。 😎

您將學到什麼:👀

  • 了解如何使用 OpenAI TTS / Pyttsx3 / gTTS 在 Python 專案中設定TTS

  • 了解使用GroqLLAMA-3模型產生聊天回應。

  • 學習捕捉網路攝影機影像並使用 Google Generative AI 對其進行處理。

  • 學習使用shell 腳本自動執行所有手動任務。

讓我們一起建造這個東西! 😵‍💫

準備好了 GIF


設定環境🛠️

建立一個資料夾來保存專案的所有原始程式碼:

mkdir siri-voice-llama3
cd siri-voice-llama3

建立一些新的子資料夾,我們將在其中儲存原始程式碼、shell 腳本、日誌和聊天歷史記錄:

mkdir -p src logs src/scripts data/ai_response data/chat_history

現在初始資料夾結構已經設定完畢,是時候建立一個新的虛擬環境並安裝我們將在專案中使用的所有模組了。

執行以下命令在專案的根目錄中建立並啟動一個新的虛擬環境:

python3 -m venv .venv
source .venv/bin/activate # If you are using fish shell, change the activate binary to activate.fish

執行此命令來安裝我們將在專案中使用的所有必要模組:

pip3 install SpeechRecognition opencv-python openai google-generativeai gTTS pyttsx3 groq faster-whisper numpy python-dotenv pyperclip pydub PyAudio pillow

⚠️注意:以這種方式安裝軟體包如果將來發生變化可能會導致問題。對於確切的版本,請在此處找到我的requirements.txt檔案。複製內容以在專案的根目錄中建立此檔案。

要安裝,請執行: pip3 install -r requirements.txt

以下是每個模組的用途:

  • SpeechRecognition :啟用音訊檔案或串流的語音辨識。

  • opencv-python :用於處理網路攝影機影像。

  • groq :用於使用 Groq 的函式庫。用於產生 LLAMA-3 的回應。

  • google-generativeai :用於影像處理以提供上下文。

  • faster-whisper :用於語音辨識的 Whisper 的更快實現。

  • python-dotenv :用於從 .env 檔案讀取鍵值對。

  • pyperclip :方便剪貼簿操作(複製貼上)。

  • pydub :處理音訊操作任務。

  • PyAudio :管理音訊輸入/輸出。

  • numpy :支援數值計算和高效率的陣列處理。

  • pillow :Python 影像處理庫 (PIL) 的一個分支,用於影像處理。

可選模組:

ℹ️ 其中,只需要一項。

  • openai :使用 OpenAI 的串流音訊啟用文字轉語音。

  • gTTS :Google 文字轉語音庫,用於從文字產生語音。

  • pyttsx3 :用於離線語音合成的 Python 文字轉語音庫。


讓我們來編碼吧💻

火 gif

設定聊天記錄支援 📋

💡 我們將在每天的日誌檔案中分別新增對聊天歷史記錄的支援。

src目錄中,新增一個名為utils.py的文件,其中包含以下程式碼:

在此文件中,我們將儲存程式中需要的所有輔助函數。

# 👇 siri-voice-llama3/src/utils.py

import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Literal, NoReturn, Optional

import pyperclip
from PIL import ImageGrab

import utils

def get_log_file_for_today(project_root_folder_path: Path) -> Path:
    """
    Retrieves the log file path for today's date, ensuring that the necessary
    directories are created. If the log file for the current day does not exist,
    it creates an empty log file.

    Args:
        project_root_folder_path (Path): The root folder of the project, where the 'data'
        directory resides.

    Returns:
        Path: The absolute path to the log file for today's date.
    """

    today = datetime.today()

    # The year is always 4 digit and the month, day is always 2 digit using this format.
    year = today.strftime("%Y")
    month = today.strftime("%m")
    day = today.strftime("%d")

    base_folder = os.path.join(
        project_root_folder_path, "data", "chat_history", year, month
    )

    os.makedirs(base_folder, exist_ok=True)
    chat_log_file = os.path.join(base_folder, f"{day}.log")

    Path(chat_log_file).touch(exist_ok=True)

    return Path(os.path.abspath(chat_log_file))

def log_chat_message(
    log_file_path: Path,
    user_message: Optional[str] = None,
    ai_message: Optional[str] = None,
) -> None:
    """
    Logs user and assistant chat messages to the provided log file, along with
    a timestamp. Either the user message or the assistant message (or both) can
    be provided.

    Args:
        log_file_path (Path): The absolute path to the log file where messages will be logged.
        user_message (Optional[str]): The message sent by the user. Defaults to None.
        ai_message (Optional[str]): The message generated by the assistant. Defaults to None.

    Returns:
        None: This function appends the messages to the log file in a readable format
        with a timestamp. It does not return anything.
    """

    # If neither of the message is given, return.
    if not user_message and not ai_message:
        return

    timestamp = datetime.now().strftime("[%H : %M : %S]")

    with open(log_file_path, "a") as log_file:
        if user_message:
            log_file.write(f"{timestamp} - USER: {user_message}")

        if ai_message:
            log_file.write(f"{timestamp} - ASSISTANT: {ai_message}\n")

        log_file.write("\n")

get_log_file_for_today函數接受專案根資料夾的路徑,該資料夾通常是我們的main.py檔案所在的位置。

它建構了儲存在data/chat_history/{month}/{day}.log中的今天日誌檔案的路徑。如果該檔案不存在,它將建立一個空檔案並返迴路徑。如果確實存在,則僅返回現有路徑。

log_chat_message函數取得日誌檔案、使用者訊息和 AI 訊息的路徑,然後使用特定時間戳記記錄接收到的訊息。


API 金鑰配置🔑

對於這個專案,我們需要一些 API 金鑰。其中包括 Groq 金鑰、Google Generative AI 金鑰和可選的 OpenAI 金鑰。

在專案的根目錄中建立一個新檔案.env並使用 API 金鑰填充它。

# Required
GROQ_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY=

# Optional
OPENAI_API_KEY=

使用 API 金鑰填充.env檔後,就可以在 Python 程式碼中存取它了。

src目錄中,使用以下程式碼建立一個新檔案setup.py

# 👇 siri-voice-llama3/src/setup.py

import os

from dotenv import load_dotenv

import utils

def get_credentials() -> tuple[str, str, str | None]:
    """
    Load API keys from environment variables and return them as a tuple.

    This function loads environment variables from a `.env` file using `dotenv`.
    It retrieves the Groq API key, Google Generative AI API key, and OpenAI API key.
    If any of the keys are missing, it exits the program with an error message.

    Returns:
        tuple[str, str, str | None]: A tuple containing the Groq API key, Google Generative AI API key,
                              and OpenAI API key.

    Raises:
        SystemExit: If any of the required API keys are not found, the program exits with an error message.
    """
    load_dotenv()

    groq_api_key: str | None = os.getenv("GROQ_API_KEY")
    google_gen_ai_api_key: str | None = os.getenv("GOOGLE_GENERATIVE_AI_API_KEY")
    openai_api_key: str | None = os.getenv("OPENAI_API_KEY")

    if groq_api_key is None or google_gen_ai_api_key is None:
        return utils.exit_program(
            status_code=1,
            message="Missing required API key(s). Make sure to set them in `.env` file. If you are using the OpenAI approach, then populate the OpenAI api key as well.",
        )

    return groq_api_key, google_gen_ai_api_key, openai_api_key

get_credentials函數使用dotenv庫從環境變數載入 API 金鑰,並將它們作為元組傳回。

如果 Groq 或 Google API 金鑰遺失,函數將退出程式並顯示錯誤訊息,提示使用者在.env檔案中設定必要的金鑰。 OpenAI 金鑰作為可選返回,如果未設置,則可以為 None。


定義額外的輔助函數👷

上面setup.py中的get_credentials函數中,我們使用了utils.exit_program ,但我們還沒有定義它。

讓我們繼續努力並加入一些我們在專案中需要的更多輔助函數。

src目錄下的utils.py檔中,加入以下程式碼行:

# 👇 siri-voice-llama3/src/utils.py

# Rest of the code...

def exit_program(status_code: int = 0, message: str = "") -> NoReturn:
    """
    Exit the program with an optional error message.

    Args:
        status_code (int): The exit status code. Defaults to 0 (success).
        message (str): An optional error message to display before exiting.
    """

    if message:
        print(f"ERROR: {message}\n")
    sys.exit(status_code)

def get_path_to_folder(folder_type: Literal["webcam", "screenshot"]) -> Path:
    """
    Get the path to the specified folder type (webcam or screenshot).

    Args:
        folder_type (Literal["webcam", "screenshot"]): The type of folder to retrieve the path for.

    Returns:
        Path: The path to the specified folder.

    Raises:
        ValueError: If the folder_type is not valid.
    """

    base_path = Path(os.path.join(Path.home(), "Pictures", "llama3.1"))
    folder_map = {
        "screenshot": Path(os.path.join(base_path, "Screenshots")),
        "webcam": Path(os.path.join(base_path, "Webcam")),
    }

    if folder_type not in folder_map:
        raise ValueError(
            f"ERROR: Invalid folder_type: {folder_type}. Expected 'webcam' or 'screenshot'."
        )

    return folder_map[folder_type]

exit_program顧名思義,它使用指定的狀態程式碼和可選的錯誤訊息退出程式。如果提供了訊息,它會在退出之前列印該訊息。

get_path_to_folder函數建構並傳回指定資料夾類型(「網路攝影機」或「螢幕截圖」)的資料夾路徑。它將使用者的主目錄與預先定義的基本路徑(“Pictures/llama3.1”)組合在一起,並附加相關的資料夾名稱。我們將使用此功能將圖像儲存在適當的資料夾中,用於網路攝影機或螢幕截圖。

現在,我們將定義更多的函數來處理捕獲和刪除螢幕截圖以及獲取剪貼簿文字。

# 👇 siri-voice-llama3/src/utils.py

# Rest of the code...

def capture_screenshot() -> Path:
    """
    Captures a screenshot and saves it to the designated folder.

    Returns:
        Path: The file path of the saved screenshot.
    """

    screenshot_folder_path = utils.get_path_to_folder(folder_type="screenshot")

    os.makedirs(screenshot_folder_path, exist_ok=True)

    screen = ImageGrab.grab()

    time_stamp = datetime.now().strftime("%Y_%m_%d_%H_%M_%S")
    rgb_screenshot = screen.convert("RGB")

    image_filename = f"screenshot_{time_stamp}.png"
    image_file_path = Path(os.path.join(screenshot_folder_path, image_filename))

    rgb_screenshot.save(image_file_path, quality=20)

    return image_file_path

def remove_last_screenshot() -> None:
    """
    Remove the most recent screenshot file from the designated screenshots folder.

    The function checks if the folder exists and if there are any .png files. If
    found, it deletes the most recently created screenshot.
    """

    folder_path = utils.get_path_to_folder(folder_type="screenshot")

    if not os.path.exists(folder_path):
        return

    files = [
        file
        for file in os.listdir(folder_path)
        if os.path.isfile(os.path.join(folder_path, file)) and file.endswith(".png")
    ]
    if not files:
        return

    most_recent_file = max(
        files, key=lambda f: os.path.getctime(os.path.join(folder_path, f))
    )

    os.remove(os.path.join(folder_path, most_recent_file))

def get_clipboard_text() -> str:
    """
    Retrieves the current text content from the system clipboard.

    This function uses the `pyperclip` module to access the clipboard. If the clipboard
    content is a valid string, it returns the content. If the content is not a string,
    it returns an empty string.

    Returns:
        str: The text content from the clipboard, or an empty string if the content is
        not a string or the clipboard is empty.
    """

    clipboard_content = pyperclip.paste()

    if isinstance(clipboard_content, str):
        return clipboard_content

    return ""

capture_screenshot函數使用ImageGrab模組捕獲當前螢幕,將其作為 PNG 檔案保存在指定的螢幕截圖資料夾中,並傳回所儲存螢幕截圖的完整檔案路徑。 📸

它使用時間戳來建立檔案名稱以確保唯一性,並將保存的影像的品質設為 20。我們正在降低影像的質量,以便稍後可以快速處理影像。

remove_last_screenshot函數從指定資料夾中辨識並刪除最近建立的螢幕截圖。它首先檢查該資料夾是否存在並在其中查找 .png 檔案。如果找到文件,它會使用建立時間來確定最近的文件,然後再刪除。 🚮

get_clipboard_text函數使用pyperclip模組來存取剪貼簿。如果剪貼簿內容是有效字串,則傳回該內容。如果內容不是字串,則傳回空字串。


整合網路攝影機支援📸

為了從網路攝影機捕捉影像,我們必須加入對其的支援。

src目錄中,建立一個新檔案webcam.py並加入以下程式碼行:

# 👇 siri-voice-llama3/src/webcam.py

import os
from datetime import datetime
from pathlib import Path
from typing import NoReturn, Union

import cv2

import utils

def get_available_webcam() -> cv2.VideoCapture | None:
    """
    Checks for available webcams and returns the first one that is opened.

    This function attempts to open the first 10 webcam indices. If a webcam is found
    and successfully opened, it returns a VideoCapture object. If no webcams are found,
    it exits the program with an error message.

    Returns:
        cv2.VideoCapture: The opened webcam object.
        None: If no webcam is found, the program exits with an error message.
    """

    # Assuming that we are checking the first 10 webcams.
    for index in range(10):
        web_cam = cv2.VideoCapture(index)
        if web_cam.isOpened():
            return web_cam

    return utils.exit_program(status_code=1, message="No webcams found.")

def capture_webcam_image() -> Union[Path, NoReturn]:
    """
    Captures an image from the available webcam and saves it to the specified folder.

    This function first checks for an available webcam using `get_available_webcam`.
    If a webcam is successfully opened, it creates a folder for saving the images if
    it does not already exist, generates a timestamped filename, captures a frame,
    and saves the image to the specified folder. The function then releases the webcam.

    Returns:
        Path: The file path of the saved image.
        NoReturn: If there was an error capturing the image, the program exits with an error message.
    """

    webcam = get_available_webcam()
    if webcam is None or not webcam.isOpened():
        return utils.exit_program(
            status_code=1, message="There was an error capturing the image."
        )

    webcam_folder_path = utils.get_path_to_folder(folder_type="webcam")

    os.makedirs(webcam_folder_path, exist_ok=True)

    timestamp = datetime.now().strftime("%Y_%m_%d_%H_%M_%S")
    image_filename = f"webcam_{timestamp}.png"

    _, frame = webcam.read()

    image_file_path_str = os.path.join(webcam_folder_path, image_filename)

    cv2.imwrite(image_file_path_str, frame)

    webcam.release()

    return Path(image_file_path_str)

get_available_webcam函數檢查可用的網路攝像頭,假設前十個索引。如果成功開啟網路攝影機,則傳回對應的cv2.VideoCapture物件。 🎥 如果未找到網路攝影機,則會退出程式並顯示錯誤訊息。

capture_webcam_image函數從可用的網路攝影機擷取影像。它首先呼叫我們之前寫的輔助函數get_available_webcam來取得可用的網路攝影機。如果成功,它將建立一個用於保存影像的資料夾(如果尚不存在),產生帶有時間戳記的檔案名,捕獲幀並保存影像。最後,它釋放網路攝影機並返回保存影像的路徑。 🖼️


實作主程式邏輯😵‍💫

現在我們已經編寫了處理專案時需要的所有實用程序,讓我們從主程式邏輯開始

src目錄中,建立一個新檔案siri.py並新增以下程式碼行:

# 👇 siri-voice-llama3/src/siri.py

import os
import re
import time
from pathlib import Path
from typing import List

import google.generativeai as genai
import pyttsx3
import speech_recognition as sr
from faster_whisper import WhisperModel
from groq import Groq
from groq.types.chat import ChatCompletionMessageParam
from gtts import gTTS
from openai import OpenAI
from PIL import Image
from pydub import AudioSegment
from pydub.playback import play

import utils
import webcam

class Siri:
    """
    A multi-modal AI voice assistant that responds to user prompts
    by processing voice commands and context from images or clipboard content.
    """

    def __init__(
        self,
        log_file_path: Path,
        project_root_folder_path: Path,
        groq_api_key: str,
        google_gen_ai_api_key: str,
        openai_api_key: str | None,
    ) -> None:
        """
        Initializes the Siri assistant with API clients for Groq, OpenAI, and Google Generative AI.

        Args:
            log_file_path (Path): Path to the log file.
            project_root_folder_path (Path): Root folder of the project.
            groq_api_key (str): API key for Groq.
            google_gen_ai_api_key (str): API key for Google Generative AI.
            openai_api_key (str): API key for OpenAI.
        """
        self.log_file_path = log_file_path
        self.project_root_folder_path = project_root_folder_path

        self.pyttsx3_engine = pyttsx3.init()

        self.groq_client = Groq(api_key=groq_api_key)
        self.openai_client = OpenAI(api_key=openai_api_key)

        # Configure Google Generative AI model
        genai_generation_config = genai.GenerationConfig(
            temperature=0.7, top_p=1, top_k=1, max_output_tokens=2048
        )
        genai.configure(api_key=google_gen_ai_api_key)

        self.genai_model = genai.GenerativeModel(
            "gemini-1.5-flash-latest",
            generation_config=genai_generation_config,
            safety_settings=[
                {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
                {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
                {
                    "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
                    "threshold": "BLOCK_NONE",
                },
                {
                    "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
                    "threshold": "BLOCK_NONE",
                },
            ],
        )

        # Initialize conversation context for the AI
        self.conversation: List[ChatCompletionMessageParam] = [
            {
                "role": "user",
                "content": (
                    "You are a multi-modal AI voice assistant. Your user may have attached a photo (screenshot or webcam capture) "
                    "for context, which has already been processed into a detailed text prompt. This will be attached to their transcribed "
                    "voice input. Generate the most relevant and factual response by carefully considering all previously generated text "
                    "before adding new information. Do not expect or request additional images; use the provided context if available. "
                    "Please do not include newlines in your response. Keep it all in one paragraph. "
                    "Ensure your responses are clear, concise, and relevant to the ongoing conversation, avoiding any unnecessary verbosity."
                ),
            }
        ]

        total_cpu_cores = os.cpu_count() or 1

        # Initialize the audio transcription model
        self.audio_transcription_model = WhisperModel(
            device="cpu",
            compute_type="int8",
            model_size_or_path="base",
            cpu_threads=total_cpu_cores // 2,
            num_workers=total_cpu_cores // 2,
        )

        # Initialize speech recognition components
        self.speech_recognizer = sr.Recognizer()
        self.mic_audio_source = sr.Microphone()
        self.wake_word = "siri"

Siri類別設定了一個多模式 AI 語音助手,不僅可以處理語音命令,還可以處理圖像或剪貼簿內容以加入上下文。

它接受一些關鍵參數,例如log_file_pathproject_root_folder_path有助於在使用 gTTS 時記錄對話並將 AI 回應儲存為 mp3 檔案。您還需要 Groq、Google Generative AI 和可選的 OpenAI 的 API 金鑰。 🤖

該課程為 Groq、OpenAI 和 Google Generative AI 設定客戶端。對於Google GenAI,它使用gemini-1.5-flash模型並調整了一些安全設定。

內建初始對話提示,指導人工智慧如何根據使用者的語音命令或任何處理後的圖像做出回應。 💬

對於音訊轉錄,它使用 Faster Whisper 模型,該模型在具有特定效能設定的 CPU 上執行。它還使用RecognizerMicrophone設定語音辨識,助理聆聽喚醒詞“siri”以開始執行命令。

現在初始配置已完成,讓我們為類別加入一些方法。我們將定義更多一些來處理音訊轉錄、提取使用者的提示、監聽提示以及使用 Groq 產生聊天回應。 🛠️

src目錄下的siri.py檔案中加入以下方法。

# 👇 siri-voice-llama3/src/siri.py

# Rest of the code...

def transcribe_audio_to_text(self, audio_file_path: Path) -> str:
    """
    Transcribes audio from a file to text.

    Args:
        audio_file_path (Path): Path to the audio file.

    Returns:
        str: The transcribed text from the audio.
    """

    segments, _ = self.audio_transcription_model.transcribe(
        audio=str(audio_file_path)
    )
    return "".join(segment.text for segment in segments)

def extract_prompt(self, transcribed_text: str) -> str | None:
    """
    Extracts the user's prompt from the transcribed text after the wake word.

    Args:
        transcribed_text (str): The transcribed text from audio input.

    Returns:
        str | None: The extracted prompt if found, otherwise None.
    """

    pattern = rf"\b{re.escape(self.wake_word)}[\s,.?!]*([A-Za-z0-9].*)"
    regex_match = re.search(
        pattern=pattern, string=transcribed_text, flags=re.IGNORECASE
    )

    if regex_match is None:
        return None

    return regex_match.group(1).strip()

def listen(self) -> None:
    """
    Starts listening for the wake word and processes audio input in the background.
    """

    with self.mic_audio_source as mic:
        self.speech_recognizer.adjust_for_ambient_noise(source=mic, duration=2)

    self.speech_recognizer.listen_in_background(
        source=self.mic_audio_source, callback=self.handle_audio_processing
    )

    while True:
        time.sleep(0.5)

def generate_chat_response_with_groq(
    self, prompt: str, image_context: str | None
) -> str:
    """
    Generates a response from the Groq model based on user input and optional image context.

    Args:
        prompt (str): The user's prompt.
        image_context (str | None): Optional image context for the response.

    Returns:
        str: The generated response from the assistant.
    """

    if image_context:
        prompt = f"USER_PROMPT: {prompt}\n\nIMAGE_CONTEXT: {image_context}"

    self.conversation.append({"role": "user", "content": prompt})

    completion = self.groq_client.chat.completions.create(
        messages=self.conversation, model="llama-3.1-8b-instant"
    )

    ai_response = completion.choices[0].message.content

    self.conversation.append({"role": "assistant", "content": ai_response})

    return ai_response or "Sorry, I'm not sure how to respond to that."

transcribe_audio_to_text方法取得音訊檔案的路徑並將其內容轉錄為文字。它使用 WhisperModel 分段處理音訊文件,並傳回一個字串,該字串連接所有分段的轉錄文字。 🎧

extract_prompt方法從轉錄文字中擷取使用者的語音提示,特別是在喚醒字詞(例如「siri」)之後。它使用正規表示式來查找並捕獲喚醒詞後的提示,返回清理後的提示,如果未找到提示,則返回None 。 🗣️

listen方法持續監聽喚醒詞並處理音訊輸入。它首先調整環境噪音,然後使用回調( handle_audio_processing )開始在背景監聽。此方法進入無限循環,在每次迭代中短暫暫停以繼續監聽。 🔄

方法generate_chat_response_with_groq根據使用者的提示和可選的圖片上下文使用Groq 模型產生回應。它使用圖像上下文(如果有)來格式化提示,將對話加入到模型中,並將人工智慧的回應附加到正在進行的對話中。然後,它會傳回產生的回應,如果未產生回應,則傳回預設訊息。 💬


文字轉語音產生🗣️

對於文字轉語音生成,我們將實施三種不同的方法。 pyttsx3、OpenAI 和 gTTS(Google文字轉語音)。您可以自由選擇適合您要求的任何方法。

  • Pyttsx3 方法

在這裡,為了產生文字轉語音,我們將使用著名的 Python 模組 Pyttsx3。

siri.py檔中,加入以下方法。

# 👇 siri-voice-llama3/src/siri.py

# Rest of the code...

# Pyttsx3 Approach (Weaker Audio Quality)
def text_to_speech(self, text: str) -> None:
    """
    Converts text to speech using Pyttsx3's text-to-speech API.

    Args:
        text (str): The text to convert to speech.
    """

    self.pyttsx3_engine.setProperty("volume", 1.0)
    self.pyttsx3_engine.setProperty("rate", 125)

    voices = self.pyttsx3_engine.getProperty("voices")

    # Set voice to Female.
    self.pyttsx3_engine.setProperty("voice", voices[0].id)

    self.pyttsx3_engine.say(text)
    self.pyttsx3_engine.runAndWait()

    self.pyttsx3_engine.stop()

text_to_speech方法使用我們在 Siri 類別中初始化的pyttsx3_engine來設定引擎的一些屬性,並最終說出我們提供給它的文字。

  • 開放人工智慧方法

對於這種方法,我們將使用 OpenAI Audio 語音串流。總體而言,此方法比任何其他方法具有最佳體驗,但它要求您設定 OpenAI API 並且您的帳戶具有一些 OpenAI 積分。

siri.py檔中,加入以下方法。

# 👇 siri-voice-llama3/src/siri.py

# Rest of the code...

# OpenAI Approach (Best Quality Audio with multiple voice available).
def text_to_speech(self, text: str) -> None:
    """
    Converts text to speech using OpenAI's text-to-speech API.

    Args:
        text (str): The text to convert to speech.
    """

    stream = pyaudio.PyAudio().open(
        format=pyaudio.paInt16, channels=1, rate=24000, output=True
    )
    stream_start = False

    with self.openai_client.audio.speech.with_streaming_response.create(
        model="tts-1", voice="nova", response_format="pcm", input=text
    ) as openai_response:
        silence_threshold = 0.1
        for chunk in openai_response.iter_bytes(chunk_size=1024):
            if stream_start:
                stream.write(chunk)

            elif max(chunk) > silence_threshold:
                stream.write(chunk)
                stream_start = True

text_to_speech方法使用 OpenAI 的文字轉語音 (TTS) API 將文字轉換為語音。

首先使用PyAudio開啟音訊串流,該串流配置為以 24,000 Hz 取樣率和 16 位元解析度輸出音訊。然後,該方法使用提供的文字呼叫 OpenAI TTS API,指定模型(“tts-1”)、語音(“nova”)和回應格式(“pcm”)。音訊資料是即時傳輸的。 🚀

如果您願意,可以更改聲音。有關可用選項的列表,請存取此處

在循環內,該方法檢查 OpenAI API 傳回的音訊區塊。如果音訊超過某個靜音閾值,串流將開始播放音訊區塊,確保僅在偵測到有意義的聲音時才朗讀文字。這可以防止流從靜音開始。 🔊

  • gTTS 方法

對於這種方法,我們將使用 Google 文字轉語音引擎。

這種方法非常慢,而且需要將 AI 回應儲存為“mp3”,然後播放該音訊檔案。

siri.py檔中,加入以下方法。

# 👇 siri-voice-llama3/src/siri.py

# Rest of the code...

def text_to_speech(self, text: str) -> None:
    """
    Converts text to speech using Google's text-to-speech API.

    Args:
        text (str): The text to convert to speech.
    """

    tts = gTTS(text=text, lang="en", slow=False)

    response_folder_path = Path(
        os.path.abspath(
            os.path.join(self.project_root_folder_path, "data", "ai_response")
        )
    )

    os.makedirs(response_folder_path, exist_ok=True)

    response_audio_file_path = Path(
        os.path.join(response_folder_path, "ai_response_audio.mp3")
    )

    tts.save(response_audio_file_path)

    response_audio = AudioSegment.from_mp3(response_audio_file_path)
    play(response_audio)

    # After the audio is played, delete the audio file.
    if os.path.exists(response_audio_file_path):
        os.remove(response_audio_file_path)

text_to_speech方法使用 Google 的 TTS API 將文字轉換為語音。它首先根據給定的英文文字產生語音,設定slow=False以加快播放速度。然後,該方法在「data/ai_response」目錄內建立用於儲存回應音訊檔案的資料夾路徑。確保目錄存在後,會將語音儲存為 mp3 檔案。

儲存 mp3 檔案後,它會使用AudioSegment載入音訊並播放。播放音訊後,該方法刪除mp3檔案進行清理。


現在,我們也研究了text_to_speech方法,如果使用者將影像上下文附加到提示,我們需要編寫更多方法來分析影像提示,選擇相關的輔助操作並處理音訊並採取相關操作。

將以下程式碼加入src目錄下的siri.py檔案中。

# 👇 siri-voice-llama3/src/siri.py

# Rest of the code...

def analyze_image_prompt(self, prompt: str, image_path: Path) -> str:
    """
    Analyzes an image based on the user prompt to extract semantic information.

    Args:
        prompt (str): The user's prompt related to the image.
        image_path (Path): The path to the image file.

    Returns:
        str: The analysis result from the image based on the prompt.
    """

    image = Image.open(image_path)
    prompt = (
        "You are an image analysis AI tasked with extracting semantic meaning from images to assist another AI in "
        "generating a user response. Your role is to analyze the image based on the user's prompt and provide all relevant, "
        "objective data without directly responding to the user. Focus solely on interpreting the image in the context of "
        f"the user’s request and relay that information for further processing. \nUSER_PROMPT: {prompt}"
    )
    genai_response = self.genai_model.generate_content([prompt, image])
    return genai_response.text

def select_assistant_action(self, prompt: str) -> str:
    """
    Determines the appropriate action for the assistant to take based on user input.

    Args:
        prompt (str): The user's prompt.

    Returns:
        str: The selected action for the assistant.
    """

    system_prompt_message = (
        "You are an AI model tasked with selecting the most appropriate action for a voice assistant. Based on the user's prompt, "
        "choose one of the following actions: ['extract clipboard', 'take screenshot', 'delete screenshot', 'capture webcam', 'generic']. "
        "Assume the webcam is a standard laptop webcam facing the user. Provide only the action without explanations or additional text. "
        "Respond strictly with the most suitable option from the list."
    )
    function_conversation: List[ChatCompletionMessageParam] = [
        {"role": "system", "content": system_prompt_message},
        {"role": "user", "content": prompt},
    ]

    completion = self.groq_client.chat.completions.create(
        messages=function_conversation, model="llama-3.1-8b-instant"
    )

    ai_response = completion.choices[0].message.content

    return ai_response or "generic"

def handle_audio_processing(self, recognizer: sr.Recognizer, audio: sr.AudioData):
    """
    Callback function to process audio input once recognized.

    Args:
        recognizer (sr.Recognizer): The speech recognizer instance.
        audio (sr.AudioData): The audio data captured by the microphone.
    """

    data_folder_path = Path(os.path.abspath(os.path.join(".", "data")))
    os.makedirs(data_folder_path, exist_ok=True)

    audio_prompt_file_path = Path(
        os.path.abspath(os.path.join(data_folder_path, "user_audio_prompt.wav"))
    )
    with open(audio_prompt_file_path, "wb") as f:
        f.write(audio.get_wav_data())

    transcribed_text = self.transcribe_audio_to_text(
        audio_file_path=audio_prompt_file_path
    )
    parsed_prompt = self.extract_prompt(transcribed_text=transcribed_text)

    if parsed_prompt:
        utils.log_chat_message(
            log_file_path=self.log_file_path, user_message=parsed_prompt
        )
        skip_response = False

        selected_assistant_action = self.select_assistant_action(
            prompt=parsed_prompt
        )

        if "capture webcam" in selected_assistant_action:
            image_path = webcam.capture_webcam_image()
            image_analysis_result = self.analyze_image_prompt(
                prompt=parsed_prompt, image_path=image_path
            )

        elif "take screenshot" in selected_assistant_action:
            image_path = utils.capture_screenshot()
            image_analysis_result = self.analyze_image_prompt(
                prompt=parsed_prompt, image_path=image_path
            )

        elif "delete screenshot" in selected_assistant_action:
            utils.remove_last_screenshot()
            image_analysis_result = None
            ai_response = "Screenshot deleted successfully."
            self.text_to_speech(text=ai_response)

            utils.log_chat_message(
                log_file_path=self.log_file_path, ai_message=ai_response
            )

            skip_response = True

        elif "extract clipboard" in selected_assistant_action:
            clipboard_content = utils.get_clipboard_text()
            parsed_prompt = (
                f"{parsed_prompt}\n\nCLIPBOARD_CONTENT: {clipboard_content}"
            )
            image_analysis_result = None

        else:
            image_analysis_result = None

        # If the response is not supposed to be skipped, then generate the response and speak it out.
        if not skip_response:
            response = self.generate_chat_response_with_groq(
                prompt=parsed_prompt, image_context=image_analysis_result
            )
            utils.log_chat_message(
                log_file_path=self.log_file_path, ai_message=response
            )
            self.text_to_speech(text=response)

    # Remove the user prompt audio after the response is generated.
    if os.path.exists(audio_prompt_file_path):
        os.remove(audio_prompt_file_path)

analyze_image_prompt方法根據使用者的提示來分析影像以提取語義資訊。它首先使用 PIL 庫打開指定的圖像檔案。然後,該方法建立一個提示,指示影像分析 AI 專注於從影像中提取相關資料,而不直接回應使用者。

該方法將建立的提示和圖像傳送到 Google Generative AI 模型進行處理。最後,它以文字形式傳回圖像分析結果。 📝

select_assistant_action方法根據使用者的輸入決定助理的適當操作。 🤔 首先建立一個系統提示,指示 AI 模型從預先定義的操作清單中選擇: 'extract clipboard', 'take screenshot', 'delete screenshot', 'capture webcam', or 'generic'

接下來,該方法建立包括系統提示和使用者提示的會話清單。然後,它將此對話傳送到 Groq 用戶端,以使用指定的模型產生回應。

回應可以是預先定義操作清單中的任何一項。

handle_audio_processing方法在助手辨識音訊輸入後對其進行處理。首先,它將捕獲的音訊作為.wav檔案保存在“data”資料夾中。然後,它使用transcribe_audio_to_text方法將音頻轉錄為文本,並使用extract_prompt從文本中提取用戶的提示。

如果找到提示,它會記錄使用者的訊息並使用select_assistant_action確定適當的助手操作。根據操作的不同,它可能會捕獲網路攝影機影像、截取螢幕截圖、刪除螢幕截圖或提取剪貼簿內容。對於基於影像的操作,它使用analyze_image_prompt來分析影像。 🔍

skip_response變數用於控制助手在執行某些操作後是否應跳過生成和說出回應。它最初設定為 False,表示預計會產生回應。

例如,當動作是「刪除螢幕截圖」時,該方法刪除螢幕截圖並透過文字轉語音直接提供預先定義的回應(「螢幕截圖已成功刪除。」)。在這種情況下, skip_response設定為 True 以防止助手為使用者提示產生單獨的回應,因為操作本身就足夠了。 ✅

對於其他操作,它使用generate_chat_response_with_groq方法產生回應並將回應轉換為語音。產生回應後,該方法刪除音訊檔案。 🚮

main.py 🧑‍💻

這將是我們程式的入口點。它執行助手執行所需的設定和初始化。

在專案的根目錄中建立一個名為main.py的新文件,並新增以下程式碼行:

# 👇 siri-voice-llama3/main.py

import os
import sys
from pathlib import Path

# Add the src directory to the module search path
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))

from src import setup, siri, utils

"""
Main entry point for the AI llama3 siri voice assitant.

This script loads the necessary API credentials from environment variables,
initializes the Siri assistant with the provided keys, and starts listening
for user input. The program will exit if any of the required API keys are
missing.

To run the application, execute this script in an environment where the
`.env` file is properly configured with the required API keys.
"""

if __name__ == "__main__":
    # Determine the current directory of the script
    project_root_folder_path = Path(os.path.dirname(os.path.abspath(__file__)))

    chat_log_file_path = utils.get_log_file_for_today(
        project_root_folder_path=project_root_folder_path
    )

    all_api_keys = setup.get_credentials()
    groq_api_key, google_gen_ai_api_key, openai_api_key = all_api_keys

    siri = siri.Siri(
        log_file_path=chat_log_file_path,
        project_root_folder_path=project_root_folder_path,
        groq_api_key=groq_api_key,
        google_gen_ai_api_key=google_gen_ai_api_key,
        openai_api_key=openai_api_key,
    )

    siri.listen()

👀 請注意,我們使用sys.path.insert()方法插入 src 目錄的路徑,以確保 Python 可以從src目錄找到並匯入模組。

主區塊首先確定專案根資料夾,然後使用utils.get_log_file_for_today取得每日日誌檔案路徑以記錄聊天訊息。

接下來,我們使用先前在編寫一些輔助函數時編寫的setup.get_credentials函數來檢索 API(適用於 Groq、Google Generative AI 和 OpenAI)。

然後,我們建立 Siri 類別的實例,並傳遞日誌檔案路徑、專案根資料夾路徑和 API 金鑰。

最後,呼叫siri.listen方法,該方法啟動助手並偵聽使用者輸入。

到目前為止,您應該擁有自己的語音助理的工作版本。 🥂


可選:建置 Shell 腳本 🧰

🤔為什麼需要寫shell腳本?

好吧,沒有必要。實際上,我自己編寫了這個 shell 腳本,認為可以透過 Linux 服務或 cron 作業等調度工具在系統重新啟動時自動執行它。但是,我無法讓它工作,因為它需要存取硬體元件(如麥克風),所以它並沒有真正說出響應(如果您找到修復程序,請告訴我)。但是,如果您想要自動執行每個手動步驟(例如建立虛擬環境、安裝相依性以及最終執行程式),則此 shell 腳本會非常方便。您也可以透過符號連結將腳本新增到路徑中,並從系統上的任何位置執行它。 😉

使用以下程式碼行在src/scripts目錄中建立一個新檔案start_siri_llama3.sh

💁 如果您使用的是fish shell,您可以在此處找到與fish 語法相同的程式碼。在src/scripts目錄中建立一個名為start_siri_llama3.fish的新文件,並加入連結中的程式碼。

# 👇 siri-voice-llama3/src/scripts/start_siri_llama3.sh

#!/usr/bin/env bash
# Using this above way of writing shebang can have some security concerns.
# See this stackoverflow thread: https://stackoverflow.com/a/72332845
# Since, I want this script to be portable for most of the users, instead of hardcoding like '#!/usr/bin/bash', I am using this way.

ERROR_USAGE="ERROR: Usage: bash {path_to_main.py}"
ERROR_FILE_NOT_FOUND="ERROR: The main.py file does not exist or is not a valid file."
ERROR_PYTHON_NOT_FOUND="ERROR: No suitable Python executable found."
ERROR_BASH_NOT_INSTALLED="ERROR: Bash shell is not installed. Please install Bash."
ERROR_ACTIVATE_NOT_FOUND="ERROR: activate file not found in '$VENV_DIR/bin'"
ERROR_UNSUPPORTED_SHELL="ERROR: Unsupported shell: '$SHELL'"
ERROR_REQUIREMENTS_NOT_FOUND="ERROR: requirements.txt file not found in '$SCRIPT_DIR'"

# Determine the script directory, virtual environment directory, and log file
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
VENV_DIR="$(realpath "$SCRIPT_DIR/../../.venv")"
LOG_FILE="$(realpath "$SCRIPT_DIR/../../logs/shell-error-bash.log")"
REQUIREMENTS_FILE_PATH="$(realpath "$SCRIPT_DIR/../../requirements.txt")"

log_and_exit() {
  local message="$1"

  echo "[$(date +"%Y-%m-%d %H:%M:%S")] $message" | tee -a $LOG_FILE
  exit 1
}

# Check if the main.py file is provided as an argument
if [ $# -ne 1 ]; then
  log_and_exit "$ERROR_USAGE"
fi

# Function to check if a file exists and has the correct extension
check_file() {
    local file_path="$1"
    local expected_extension="$2"

    if [ ! -f "$file_path" ]; then
        log_and_exit "$ERROR_FILE_NOT_FOUND"
    fi

    if ! [[ "$file_path" == *".$expected_extension" ]]; then
        log_and_exit "The file '$file_path' must be a '.$expected_extension' file."
    fi
}

# Validate the provided main.py file
check_file "$1" "py"

# Extract and validate arguments
MAIN_FILE_PATH="$(realpath "$1")"

# Find the appropriate Python executable
PYTHON_EXEC="$(command -v python3 || command -v python)"

# Ensure that the Python executable is available before creating the virtual environment
if [ ! -d "$VENV_DIR" ]; then
    if [ -z "$PYTHON_EXEC" ]; then
        log_and_exit "$ERROR_PYTHON_NOT_FOUND"
    fi

    "$PYTHON_EXEC" -m venv "$VENV_DIR"

    # Activate the virtual environment after creating it
    if [ -f "$VENV_DIR/bin/activate" ]; then
        source "$VENV_DIR/bin/activate"
    else
        log_and_exit "$ERROR_ACTIVATE_NOT_FOUND"
    fi

    PIP_EXEC_VENV = "$(command -v pip3 || command -v pip)"

    # Check if requirements.txt exists and install dependencies
    if [ -f "$REQUIREMENTS_FILE_PATH" ]; then
        "$PIP_EXEC_VENV" install -r "$REQUIREMENTS_FILE_PATH"
    else
        log_and_exit "$ERROR_REQUIREMENTS_NOT_FOUND"
    fi
fi

# Ensure that the Bash shell is installed.
if ! command -v bash &> /dev/null; then
    log_and_exit "$ERROR_BASH_NOT_INSTALLED"
fi

# Activate the virtual environment based on the shell type
if [[ "$SHELL" == *"/bash" ]]; then
    # Check if the activate file exists before sourcing it
    if [ -f "$VENV_DIR/bin/activate" ]; then
        source "$VENV_DIR/bin/activate"
    else
        log_and_exit "$ERROR_ACTIVATE_NOT_FOUND"
    fi
else
    log_and_exit "$ERROR_UNSUPPORTED_SHELL"
fi

# Set the python executable to the one from the virtual environment
PYTHON_EXEC="$(command -v python3 || command -v python)"

# Run the main.py file
"$PYTHON_EXEC" "$MAIN_FILE_PATH"

該腳本旨在自動設定和執行 Python 程序,確保在執行main.py之前準備好必要的環境。 ⚙️

首先,它檢查是否將有效的 Python 檔案 ( main.py ) 作為參數傳遞。如果沒有,它會記錄錯誤並退出。它還驗證檔案是否存在並具有正確的副檔名 ( .py )。 🐍

然後,該腳本搜尋 Python 可執行檔( python3python ),如果虛擬環境 (venv) 不存在,它將使用 Python 的venv模組建立一個。建立 venv 後,它會啟動它,並安裝requirements.txt中的依賴項(如果找到)。這個腳本確保系統上安裝了 Python 和 Bash,因為它只支援 Bash shell。

如果使用者的 shell 不是 Bash,它會記錄錯誤並退出。否則,它會使用找到的 Python 可執行檔在虛擬環境中執行 Python 腳本 ( main.py )。

現在,為了真正能夠從系統上的任何位置執行此腳本,您可以使用符號連結將其加入到您的PATH中。 🔗

通常, /usr/local/bin是我們新增自訂建置腳本的地方。首先透過執行命令確保它在您的PATH中:

echo $PATH

如果沒有將其新增至您的PATH ,那麼您可以使用以下命令將此腳本作為符號連結新增至/usr/local/bin

ln -s {absolute_path_to_script_sh/fish} /usr/local/bin/start_siri_llama3

執行此命令後,您現在應該可以從系統上的任何位置執行此程式。 🎉


結論⚡

哇! 😮‍💨我們一起做了很多事!如果您已經做到了這一步,請給自己一個當之無愧的鼓勵。到目前為止,您已經使用 LLAMA-3 AI 模型成功建立了個人 SIRI 語音助理。

本文的完整記錄原始碼可在此處取得:

https://github.com/shricodev/siri-voice-llama3.git

非常感謝您的閱讀! 🎉🫡

在下面的評論部分寫下你的想法。 👇

{% cta https://linktr.ee/shricodev

%} 在社群上關注我 🐥 {% endcta %}

{% 嵌入 https://dev.to/shricodev %}


原文出處:https://dev.to/shricodev/build-your-personal-siri-with-llama-3-like-a-pro-5h1o


共有 0 則留言