From 0d4bf8d72945563ec0f6dc77861aa1aa35f1e721 Mon Sep 17 00:00:00 2001 From: JoyboyBrian Date: Tue, 27 Aug 2024 02:20:52 +0000 Subject: [PATCH 1/4] add a demo for `financial-advisor` --- examples/financial-advisor/README.md | 38 +++ examples/financial-advisor/app.py | 278 ++++++++++++++++++ .../bank_statement_feb.pdf | Bin 0 -> 38472 bytes .../bank_statement_mar.pdf | Bin 0 -> 38709 bytes .../bank_tatement_jan.pdf | Bin 0 -> 39018 bytes examples/financial-advisor/requirements.txt | 7 + .../financial-advisor/utils/pdf_processor.py | 118 ++++++++ .../financial-advisor/utils/text_generator.py | 130 ++++++++ 8 files changed, 571 insertions(+) create mode 100644 examples/financial-advisor/README.md create mode 100644 examples/financial-advisor/app.py create mode 100644 examples/financial-advisor/assets/fake_bank_statements/bank_statement_feb.pdf create mode 100644 examples/financial-advisor/assets/fake_bank_statements/bank_statement_mar.pdf create mode 100644 examples/financial-advisor/assets/fake_bank_statements/bank_tatement_jan.pdf create mode 100644 examples/financial-advisor/requirements.txt create mode 100644 examples/financial-advisor/utils/pdf_processor.py create mode 100644 examples/financial-advisor/utils/text_generator.py diff --git a/examples/financial-advisor/README.md b/examples/financial-advisor/README.md new file mode 100644 index 00000000..8af14ac8 --- /dev/null +++ b/examples/financial-advisor/README.md @@ -0,0 +1,38 @@ +## NexaAI SDK Demo: On-device Personal Finance advisor + +### Introduction: + +- Key features: + + - On-device processing for data privacy + - Adjustable parameters (model, temperature, max tokens, top-k, top-p, etc.) + - FAISS index for efficient similarity search + - Interactive chat interface for financial queries + +- File structure: + + - `app.py`: main Streamlit application + - `utils/pdf_processor.py`: processes PDF files and creates embeddings + - `utils/text_generator.py`: handles similarity search and text generation + - `assets/fake_bank_statements`: fake bank statement for testing purpose + +### Setup: + +1. Install required packages: + +``` +pip install -r requirements.txt +``` + +2. Usage: + +- Run the Streamlit app: `streamlit run app.py` +- Upload PDF financial docs (bank statements, SEC filings, etc.) and process them. +- Use the chat interface to query your financial data + +### Resources: + +- [NexaAI | Model Hub](https://nexaai.com/models) +- [NexaAI | Inference with GGUF models](https://docs.nexaai.com/sdk/inference/gguf) +- [GitHub | FAISS](https://github.com/facebookresearch/faiss) +- [Local RAG with Unstructured, Ollama, FAISS and LangChain](https://medium.com/@dirakx/local-rag-with-unstructured-ollama-faiss-and-langchain-35e9dfeb56f1) diff --git a/examples/financial-advisor/app.py b/examples/financial-advisor/app.py new file mode 100644 index 00000000..2e9938bc --- /dev/null +++ b/examples/financial-advisor/app.py @@ -0,0 +1,278 @@ +import sys +import os +import streamlit as st +from typing import Iterator +import subprocess +import json +import shutil +import pdfplumber +from sentence_transformers import SentenceTransformer +import faiss +import numpy as np +import re +import traceback +import logging +from nexa.gguf import NexaTextInference +import utils.text_generator as tg + +# set up logging: +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# set a default model path & allow override from command line: +default_model = "gemma" +if len(sys.argv) > 1: + default_model = sys.argv[1] + +@st.cache_resource +def load_model(model_path): + st.session_state.messages = [] + nexa_model = NexaTextInference(model_path) + return nexa_model + +def generate_response(query: str) -> str: + result = tg.financial_analysis(query) + if isinstance(result, dict) and "error" in result: + return f"An error occurred: {result['error']}" + return result + +def extract_text_from_pdf(pdf_path): + try: + with pdfplumber.open(pdf_path) as pdf: + text = '' + for page in pdf.pages: + text += page.extract_text() + '\n' + return text + except Exception as e: + logger.error(f"Error extracting text from PDF {pdf_path}: {str(e)}") + return None + +def chunk_text(text, model, max_tokens=256, overlap=20): + try: + if not text: + logger.warning("Empty text provided to chunk_text function") + return [] + + sentences = re.split(r'(?<=[.!?])\s+', text) + chunks = [] + current_chunk = [] + current_tokens = 0 + + for sentence in sentences: + sentence_tokens = len(model.tokenizer.tokenize(sentence)) + if current_tokens + sentence_tokens > max_tokens: + if current_chunk: + chunks.append(' '.join(current_chunk)) + current_chunk = [sentence] + current_tokens = sentence_tokens + else: + current_chunk.append(sentence) + current_tokens += sentence_tokens + + if current_chunk: + chunks.append(' '.join(current_chunk)) + + logger.info(f"Created {len(chunks)} chunks from text") + return chunks + except Exception as e: + logger.error(f"Error chunking text: {str(e)}") + logger.error(traceback.format_exc()) + return [] + +def create_embeddings(chunks, model): + try: + if not chunks: + logger.warning("No chunks provided for embedding creation") + return None + embeddings = model.encode(chunks) + logger.info(f"Created embeddings of shape: {embeddings.shape}") + return embeddings + except Exception as e: + logger.error(f"Error creating embeddings: {str(e)}") + logger.error(traceback.format_exc()) + return None + +def build_faiss_index(embeddings): + try: + if embeddings is None or embeddings.shape[0] == 0: + logger.warning("No valid embeddings provided for FAISS index creation") + return None + dimension = embeddings.shape[1] + index = faiss.IndexFlatL2(dimension) + index.add(embeddings.astype('float32')) + logger.info(f"Built FAISS index with {index.ntotal} vectors") + return index + except Exception as e: + logger.error(f"Error building FAISS index: {str(e)}") + logger.error(traceback.format_exc()) + return None + +def process_pdfs(uploaded_files): + if not uploaded_files: + st.warning("Please upload PDF files first.") + return False + + input_dir = "./assets/input" + output_dir = "./assets/output/processed_data" + + # clear existing files in the input directory: + if os.path.exists(input_dir): + shutil.rmtree(input_dir) + os.makedirs(input_dir, exist_ok=True) + + # save uploaded files to the input directory: + for uploaded_file in uploaded_files: + with open(os.path.join(input_dir, uploaded_file.name), "wb") as f: + f.write(uploaded_file.getbuffer()) + + # process PDFs: + try: + model = SentenceTransformer('all-MiniLM-L6-v2') + all_chunks = [] + + for filename in os.listdir(input_dir): + if filename.endswith('.pdf'): + pdf_path = os.path.join(input_dir, filename) + text = extract_text_from_pdf(pdf_path) + if text is None: + logger.warning(f"Skipping {filename} due to extraction error") + continue + file_chunks = chunk_text(text, model) + if file_chunks: + all_chunks.extend(file_chunks) + st.write(f"Processed {filename}: {len(file_chunks)} chunks") + else: + logger.warning(f"No chunks created for {filename}") + + if not all_chunks: + st.warning("No valid content found in the uploaded PDFs.") + return False + + embeddings = create_embeddings(all_chunks, model) + if embeddings is None: + st.error("Failed to create embeddings.") + return False + + index = build_faiss_index(embeddings) + if index is None: + st.error("Failed to build FAISS index.") + return False + + # save the index and chunks: + os.makedirs(output_dir, exist_ok=True) + faiss.write_index(index, os.path.join(output_dir, 'pdf_index.faiss')) + np.save(os.path.join(output_dir, 'pdf_chunks.npy'), all_chunks) + + # verify files were saved & reload the FAISS index: + if os.path.exists(os.path.join(output_dir, 'pdf_index.faiss')) and \ + os.path.exists(os.path.join(output_dir, 'pdf_chunks.npy')): + # Reload the FAISS index + tg.embeddings, tg.index, tg.stored_docs = tg.load_faiss_index() + st.success("PDFs processed and FAISS index reloaded successfully!") + return True + else: + st.error("Error: Processed files not found after saving.") + return False + + except Exception as e: + st.error(f"Error processing PDFs: {str(e)}") + logger.error(f"Error processing PDFs: {str(e)}") + logger.error(traceback.format_exc()) + return False + +def check_faiss_index(): + if tg.embeddings is None or tg.index is None or tg.stored_docs is None: + tg.embeddings, tg.index, tg.stored_docs = tg.load_faiss_index() + return tg.embeddings is not None and tg.index is not None and tg.stored_docs is not None + +# Streamlit app: +def main(): + st.markdown("

On-Device Personal Finance Advisor

", unsafe_allow_html=True) + st.caption("Powered by Nexa AI SDK🐙") + + # add an empty line: + st.markdown("
", unsafe_allow_html=True) + + # check if FAISS index exists: + if not check_faiss_index(): + st.info("No processed financial documents found. Please upload and process PDFs.") + + # step 1 - file upload: + uploaded_files = st.file_uploader("Choose PDF files", accept_multiple_files=True, type="pdf") + + # step 2 - process PDFs: + if st.button("Process PDFs"): + with st.spinner("Processing PDFs..."): + if process_pdfs(uploaded_files): + st.success("PDFs processed successfully! You can now use the chat feature.") + st.rerun() + else: + st.error("Failed to process PDFs. Please check the logs for more information.") + + # add a horizontal line: + st.markdown("---") + + # original sidebar configuration: + st.sidebar.header("Model Configuration") + model_path = st.sidebar.text_input("Model path", default_model) + + if not model_path: + st.warning("Please enter a valid path or identifier for the model in Nexa Model Hub to proceed.") + st.stop() + + if "current_model_path" not in st.session_state or st.session_state.current_model_path != model_path: + st.session_state.current_model_path = model_path + st.session_state.nexa_model = load_model(model_path) + if st.session_state.nexa_model is None: + st.stop() + + st.sidebar.header("Generation Parameters") + temperature = st.sidebar.slider("Temperature", 0.0, 1.0, st.session_state.nexa_model.params["temperature"]) + max_new_tokens = st.sidebar.slider("Max New Tokens", 1, 500, st.session_state.nexa_model.params["max_new_tokens"]) + top_k = st.sidebar.slider("Top K", 1, 100, st.session_state.nexa_model.params["top_k"]) + top_p = st.sidebar.slider("Top P", 0.0, 1.0, st.session_state.nexa_model.params["top_p"]) + + st.session_state.nexa_model.params.update({ + "temperature": temperature, + "max_new_tokens": max_new_tokens, + "top_k": top_k, + "top_p": top_p, + }) + + # step 3 - interactive financial analysis chat: + st.header("Let's discuss your finances🧑‍💼") + + if check_faiss_index(): + if "messages" not in st.session_state: + st.session_state.messages = [] + + for message in st.session_state.messages: + with st.chat_message(message["role"]): + st.markdown(message["content"]) + + if prompt := st.chat_input("Ask about your financial documents..."): + st.session_state.messages.append({"role": "user", "content": prompt}) + with st.chat_message("user"): + st.markdown(prompt) + + with st.chat_message("assistant"): + response_placeholder = st.empty() + full_response = "" + for chunk in generate_response(prompt): + choice = chunk["choices"][0] + if "delta" in choice: + delta = choice["delta"] + content = delta.get("content", "") + elif "text" in choice: + delta = choice["text"] + content = delta + + full_response += content + response_placeholder.markdown(full_response, unsafe_allow_html=True) + + st.session_state.messages.append({"role": "assistant", "content": full_response}) + else: + st.info("Please upload and process PDF files before using the chat feature.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/financial-advisor/assets/fake_bank_statements/bank_statement_feb.pdf b/examples/financial-advisor/assets/fake_bank_statements/bank_statement_feb.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6188019a0862a1fa2a21074869dd190d7940e030 GIT binary patch literal 38472 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 XWD4Gc@{>QfEEJAp77|j%sxbctYi?RZ literal 0 HcmV?d00001 diff --git a/examples/financial-advisor/assets/fake_bank_statements/bank_statement_mar.pdf b/examples/financial-advisor/assets/fake_bank_statements/bank_statement_mar.pdf new file mode 100644 index 0000000000000000000000000000000000000000..db19f56613926d4ba878d1ca8962fdc053076db3 GIT binary patch literal 38709 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

38iX9KUx8bw^#RN4xcUZKZuuy4Eff=~h)w>ZxL_ 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 G;U>BH8U{x;4+A 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%0DMxQv<7pZaow=Ejpr0+ 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 literal 0 HcmV?d00001 diff --git a/examples/financial-advisor/requirements.txt b/examples/financial-advisor/requirements.txt new file mode 100644 index 00000000..cd17171c --- /dev/null +++ b/examples/financial-advisor/requirements.txt @@ -0,0 +1,7 @@ +nexaai +pdfplumber +sentence-transformers +faiss-cpu +langchain +langchain-community +streamlit \ No newline at end of file diff --git a/examples/financial-advisor/utils/pdf_processor.py b/examples/financial-advisor/utils/pdf_processor.py new file mode 100644 index 00000000..4f6788cc --- /dev/null +++ b/examples/financial-advisor/utils/pdf_processor.py @@ -0,0 +1,118 @@ +import warnings +warnings.filterwarnings("ignore", message=".*clean_up_tokenization_spaces.*") + +import os +import pdfplumber +from sentence_transformers import SentenceTransformer +import faiss +import numpy as np +import re + +input_dir = './assets/input/' +output_dir = './assets/output/processed_data/' + +# extract pdf file one by one: +def extract_text_from_pdf(pdf_path): + print(f"1️⃣ Extracting text from {pdf_path}") + with pdfplumber.open(pdf_path) as pdf: + text = '' + for i, page in enumerate(pdf.pages): + page_text = page.extract_text() + text += page_text + '\n' + print(f"Processed page {i+1}/{len(pdf.pages)}") + return text + +# chunk the file by tokens: +def chunk_text(text, model, max_tokens=256, overlap=20): + print("2️⃣ Chunking extracted text ...") + + # split the text into definitions and other parts + definitions = re.findall(r'"\w+(?:\s+\w+)*"\s+means.*?(?="\w+(?:\s+\w+)*"\s+means|\Z)', text, re.DOTALL) + other_parts = re.split(r'"\w+(?:\s+\w+)*"\s+means.*?(?="\w+(?:\s+\w+)*"\s+means|\Z)', text) + + chunks = [] + + for definition in definitions: + if len(model.tokenizer.tokenize(definition)) <= max_tokens: + chunks.append(definition.strip()) + else: + # if a definition is too long, split it into smaller parts + sentences = re.split(r'(?<=[.!?])\s+', definition) + current_chunk = [] + current_tokens = 0 + for sentence in sentences: + sentence_tokens = len(model.tokenizer.tokenize(sentence)) + if current_tokens + sentence_tokens > max_tokens: + chunks.append(' '.join(current_chunk).strip()) + current_chunk = [sentence] + current_tokens = sentence_tokens + else: + current_chunk.append(sentence) + current_tokens += sentence_tokens + if current_chunk: + chunks.append(' '.join(current_chunk).strip()) + + # process other parts + for part in other_parts: + if part.strip(): + sentences = re.split(r'(?<=[.!?])\s+', part) + current_chunk = [] + current_tokens = 0 + for sentence in sentences: + sentence_tokens = len(model.tokenizer.tokenize(sentence)) + if current_tokens + sentence_tokens > max_tokens: + chunks.append(' '.join(current_chunk).strip()) + current_chunk = [sentence] + current_tokens = sentence_tokens + else: + current_chunk.append(sentence) + current_tokens += sentence_tokens + if current_chunk: + chunks.append(' '.join(current_chunk).strip()) + + chunks = [chunk for chunk in chunks if chunk.strip()] # remove empty chunks, if any + + chunk_sizes = [len(model.tokenizer.tokenize(chunk)) for chunk in chunks] + print(f"👉 Created {len(chunks)} chunks") + print(f" Chunk sizes: min={min(chunk_sizes)}, max={max(chunk_sizes)}, avg={sum(chunk_sizes)/len(chunk_sizes):.1f}") + # print(f"👀{chunks}") + + return chunks + +# create embeddings for all chunks at once: +def create_embeddings(chunks, model): + print("3️⃣ Creating embeddings ...") + embeddings = model.encode(chunks) + print(f"👉 Created embeddings of shape: {embeddings.shape}") + return embeddings + +# add embeddings to FAISS index: +def build_faiss_index(embeddings): + print("4️⃣ Building FAISS index ...") + dimension = embeddings.shape[1] + index = faiss.IndexFlatL2(dimension) + index.add(embeddings.astype('float32')) + print(f"👉 Added {len(embeddings)} vectors to FAISS index") + return index + +model = SentenceTransformer('all-MiniLM-L6-v2') + +# process the pdf files: +all_chunks = [] +for filename in os.listdir(input_dir): + if filename.endswith('.pdf'): + pdf_path = os.path.join(input_dir, filename) + text = extract_text_from_pdf(pdf_path) + file_chunks = chunk_text(text, model) # using default overlap (20) + all_chunks.extend(file_chunks) + print(f" File: {filename}, Chunks: {len(file_chunks)}") +print(f"✅ Total chunks from all PDFs: {len(all_chunks)}") + +embeddings = create_embeddings(all_chunks, model) +faiss_index = build_faiss_index(embeddings) + +# save the index and chunks: +print("5️⃣ Saving FAISS index and chunks ...") +os.makedirs(output_dir, exist_ok=True) +faiss.write_index(faiss_index, os.path.join(output_dir, 'pdf_index.faiss')) +np.save(os.path.join(output_dir, 'pdf_chunks.npy'), all_chunks) diff --git a/examples/financial-advisor/utils/text_generator.py b/examples/financial-advisor/utils/text_generator.py new file mode 100644 index 00000000..f792916e --- /dev/null +++ b/examples/financial-advisor/utils/text_generator.py @@ -0,0 +1,130 @@ +import os +import faiss +import numpy as np +import logging +from nexa.gguf import NexaTextInference +from langchain_community.embeddings import HuggingFaceEmbeddings +from langchain_core.documents import Document + +# set up logging: +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# model initialization: +model_path = "gemma" +inference = NexaTextInference( + model_path=model_path, + stop_words=[], + temperature=0.7, + max_new_tokens=256, + top_k=50, + top_p=0.9, + profiling=False +) + +print(f"Model loaded: {inference.downloaded_path}") +print(f"Chat format: {inference.chat_format}") + +# global variables: +embeddings = None +index = None +stored_docs = None + +# load FAISS index: +def load_faiss_index(): + global embeddings, index, stored_docs + try: + embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2") + + faiss_index_dir = "./assets/output/processed_data" + + if not os.path.exists(faiss_index_dir): + logger.warning(f"FAISS index directory not found: {faiss_index_dir}") + return None, None, None + + index_file = os.path.join(faiss_index_dir, "pdf_index.faiss") + if not os.path.exists(index_file): + logger.warning(f"FAISS index file not found: {index_file}") + return None, None, None + + index = faiss.read_index(index_file) + logger.info(f"FAISS index loaded successfully.") + + # load the chunks: + doc_file = os.path.join(faiss_index_dir, "pdf_chunks.npy") + stored_docs = np.load(doc_file, allow_pickle=True) + logger.info(f"Loaded {len(stored_docs)} documents") + + # convert stored_docs to a list of Document objects: + if not isinstance(stored_docs[0], Document): + stored_docs = [Document(page_content=doc) for doc in stored_docs] + + return embeddings, index, stored_docs + + except Exception as e: + logger.error(f"Error loading FAISS index: {str(e)}") + return None, None, None + +# load the index at module level: +embeddings, index, stored_docs = load_faiss_index() + +def custom_search(query, k=3): + global embeddings, index, stored_docs + if embeddings is None or index is None or stored_docs is None: + logger.error("FAISS index or embeddings not properly loaded") + return [] + try: + query_vector = embeddings.embed_query(query) + scores, indices = index.search(np.array([query_vector]), k) + docs = [stored_docs[i] for i in indices[0]] + return list(zip(docs, scores[0])) + except Exception as e: + logger.error(f"Error in custom_search: {str(e)}") + return [] + +# truncate text to a specific token limit: +def truncate_text(text, max_tokens=256): + tokens = text.split() + if len(tokens) <= max_tokens: + return text + return ' '.join(tokens[:max_tokens]) + +# query FAISS and generate LLM response: +def financial_analysis(query): + global embeddings, index, stored_docs + try: + if embeddings is None or index is None or stored_docs is None: + logger.error("FAISS index not loaded. Please process PDF files first.") + return {"error": "FAISS index not loaded. Please process PDF files first."} + + relevant_docs = custom_search(query, k=1) + if not relevant_docs: + logger.warning("No relevant documents found for the query.") + return {"error": "No relevant documents found for the query."} + + context = "\n".join([doc.page_content for doc, _ in relevant_docs]) + + # truncate the context if it's too long: + truncated_context = truncate_text(context) + + prompt = f"Financial context: {truncated_context}\n\nAnalyze: {query}" + + prompt_tokens = len(prompt.split()) + logger.info(f"Prompt length: {prompt_tokens} tokens") + + if prompt_tokens > 250: + prompt = truncate_text(prompt, 250) + logger.info(f"Truncated prompt length: {len(prompt.split())} tokens") + + llm_input = [ + {"role": "user", "content": prompt} + ] + + # return the iterator + return inference.create_chat_completion(llm_input, stream=True) + + except Exception as e: + logger.error(f"Error in financial_analysis: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return {"error": str(e)} From c75090b56cc5ea0db60cbc187d8064df082ba286 Mon Sep 17 00:00:00 2001 From: JoyboyBrian Date: Tue, 27 Aug 2024 06:08:48 +0000 Subject: [PATCH 2/4] add demo for `voice-transcript` --- examples/voice_transcription/README.md | 71 +++++++++ examples/voice_transcription/app.py | 96 +++++++++++++ examples/voice_transcription/requirements.txt | 9 ++ .../voice_transcription/utils/segmenter.py | 92 ++++++++++++ .../voice_transcription/utils/transcriber.py | 136 ++++++++++++++++++ 5 files changed, 404 insertions(+) create mode 100644 examples/voice_transcription/README.md create mode 100644 examples/voice_transcription/app.py create mode 100644 examples/voice_transcription/requirements.txt create mode 100644 examples/voice_transcription/utils/segmenter.py create mode 100644 examples/voice_transcription/utils/transcriber.py diff --git a/examples/voice_transcription/README.md b/examples/voice_transcription/README.md new file mode 100644 index 00000000..9f7f9a34 --- /dev/null +++ b/examples/voice_transcription/README.md @@ -0,0 +1,71 @@ +# NexaAI SDK Demo: Voice Transcription + +## Introduction + +This demo application showcases the capabilities of the NexaAI SDK for real-time voice transcription. The application is built using Streamlit and leverages the NexaAI SDK to transcribe audio in real-time, providing features like language translation, text summarization, and on-device processing for enhanced data privacy. + +### Key Features + +- **Real-Time Voice Transcription**: Transcribe audio in real-time using the NexaAI SDK. +- **Language Translation**: Supports translation of transcribed audio into different languages. +- **Text Summarization**: Generate summaries of the transcribed text using the NexaAI Text Inference model. +- **On-device Processing**: Ensures data privacy by processing audio locally on the device. +- **Interactive User Interface**: A user-friendly interface for managing transcription, summarization, and file uploads. + +### File Structure + +- **`app.py`**: The main Streamlit application that handles the user interface and controls the transcription and summarization processes. +- **`utils/segmenter.py`**: A utility module for segmenting audio streams using WebRTC VAD (Voice Activity Detection). +- **`utils/transcriber.py`**: Contains classes for handling real-time transcription and text inference. + +## Setup + +### 1. Install Required Packages + +Install the required packages by running: + +```bash +pip install -r requirements.txt + +``` + +### 2. Usage + +#### Running the Application + +To start the Streamlit application, use the following command: + +```bash +streamlit run app.py +``` + +#### Features + +- **Real-Time Transcription**: Use the "Start Recording" button to begin transcribing audio in real-time. +- **Stop Recording**: Click "Stop" to end the recording session. +- **Upload Audio Files**: Upload `.wav` files for transcription using the file uploader. +- **Generate Summary**: After transcription, generate a summary of the text by clicking the "Generate Summary" button. +- **Download Transcription**: Download the transcribed text as a `.txt` file. + +### 3. File Processing + +- **Transcription**: The application can transcribe both live audio and pre-recorded `.wav` files. +- **Summarization**: Generate a concise summary of the transcribed text. +- **Translation**: If enabled, the application can translate the transcribed text into the specified language. + + +## Code Overview + +### `app.py` + +The main application script, handling the Streamlit interface, model configuration, and the transcription process. Users can start or stop recording, upload audio files, generate summaries, and download transcriptions directly from the app interface. + +### `utils/segmenter.py` + +A utility for segmenting audio streams using WebRTC's Voice Activity Detection (VAD). This module manages the audio stream, detecting when speech occurs and splitting the audio into manageable chunks for transcription. + +### `utils/transcriber.py` + +Handles the transcription process, including initializing the NexaAI Voice Inference model, processing audio chunks, and managing the transcription lifecycle. It also includes a `TextInference` class for generating summaries from the transcribed text using the NexaAI Text Inference model. + +--- diff --git a/examples/voice_transcription/app.py b/examples/voice_transcription/app.py new file mode 100644 index 00000000..a26cc517 --- /dev/null +++ b/examples/voice_transcription/app.py @@ -0,0 +1,96 @@ +import streamlit as st +from utils.transcriber import RealTimeTranscriber, TextInference # Ensure this import matches the correct module path + +def main(): + st.title("Nexa AI Voice Transcription") + st.caption("Powered by Nexa AI SDK🐙") + + st.sidebar.header("Model Configuration") + + # Sidebar inputs for model configuration + model_path = st.sidebar.text_input("Model Path", "faster-whisper-tiny") + beam_size = st.sidebar.slider("Beam Size", 1, 10, 5) + task = st.sidebar.selectbox("Task", ["transcribe", "translate"], index=0) + temperature = st.sidebar.slider("Temperature", 0.0, 1.0, 0.0, step=0.1) + language = st.sidebar.text_input("Language", "").strip() + + # Use None if the language input is empty + language = language if language else None + + transcriber = RealTimeTranscriber( + model_path=model_path, + beam_size=beam_size, + task=task, + temperature=temperature, + language=language + ) + + # Initialize TextInference for generating summaries + text_inference = TextInference() + + # Initialize transcription in session state if not already initialized + if "transcription" not in st.session_state: + st.session_state["transcription"] = "" + + # Placeholder for transcription outside the columns for full width + transcription_container = st.empty() + transcription_container.text_area("Transcription", value=st.session_state["transcription"], height=300, key="transcription_area") + + # Start and Stop buttons + col1, col2, col3, col4 = st.columns(4) + with col1: + if st.button("Start Recording"): + transcriber.start_recording_foreground(transcription_container) + + with col2: + if st.button("Stop"): + transcriber.stop_recording_foreground(transcription_container) + + with col3: + if st.button("Reset"): + transcriber.reset_transcription() + + with col4: + # Display download button directly without requiring another click + if st.session_state["transcription"]: + transcription_bytes = st.session_state["transcription"].encode() + st.download_button( + label="Download Transcription", + data=transcription_bytes, + file_name="transcription.txt", + mime="text/plain", + ) + + # File uploader for transcription + uploaded_file = st.file_uploader("Upload a .wav file", type=["wav"]) + if uploaded_file is not None: + if st.button("Transcribe Uploaded Audio"): + with st.spinner("Transcribing uploaded audio..."): + transcription = transcriber.transcribe_audio(uploaded_file) + if transcription: + st.session_state["transcription"] += transcription + transcription_container.text_area("Transcription", value=st.session_state["transcription"], height=300, key="transcription_area_uploaded") + else: + st.error("Transcription failed. Please try again.") + + # Button to generate summary + if st.session_state["transcription"] and st.button("Generate Summary"): + with st.spinner("Generating summary..."): + summary_prompt = f"Please summarize the following transcription:\n\n{st.session_state['transcription']}\n\nSummary:" + summary = "" + summary_area = st.empty() + + for i, chunk in enumerate(text_inference.generate_summary(summary_prompt)): + if not chunk: + continue + + summary += chunk + summary_area.text_area("Summary", value=summary, height=200) + + st.session_state["summary"] = summary + + # Status message + st.write(st.session_state.get("recording_status", "Press 'Start Recording' to begin.")) + +if __name__ == "__main__": + main() diff --git a/examples/voice_transcription/requirements.txt b/examples/voice_transcription/requirements.txt new file mode 100644 index 00000000..2c0b9ef5 --- /dev/null +++ b/examples/voice_transcription/requirements.txt @@ -0,0 +1,9 @@ +nexaai +webrtcvad +streamlit +wave + +# Audio processing +# Note: PyAudio might require system-level dependencies. +# If installation fails, please refer to the README for additional instructions. +pyaudio \ No newline at end of file diff --git a/examples/voice_transcription/utils/segmenter.py b/examples/voice_transcription/utils/segmenter.py new file mode 100644 index 00000000..d05ffb03 --- /dev/null +++ b/examples/voice_transcription/utils/segmenter.py @@ -0,0 +1,92 @@ +import collections +import webrtcvad +import pyaudio +import streamlit as st +import logging + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +class Segmenter: + def __init__(self, vad_mode=3, sample_rate=16000, chunk_duration_ms=20, padding_duration_ms=200, min_segment_duration_s=1, frames_per_buffer=None): + self.vad = webrtcvad.Vad() + self.vad.set_mode(vad_mode) + self.sample_rate = sample_rate + self.chunk_duration_ms = chunk_duration_ms + self.padding_duration_ms = padding_duration_ms + self.min_segment_duration_s = min_segment_duration_s + self.frames_per_buffer = frames_per_buffer or int(self.sample_rate / 1000 * self.chunk_duration_ms) + self.stream = None + self.running = False + + def start_stream(self): + try: + p = pyaudio.PyAudio() + self.stream = p.open(format=pyaudio.paInt16, + channels=1, + rate=self.sample_rate, + input=True, + frames_per_buffer=self.frames_per_buffer) + self.running = True + logger.info(f"Audio stream started with format: {pyaudio.paInt16}, channels: 1, rate: {self.sample_rate}") + except Exception as e: + st.error(f"Error initializing audio stream: {e}") + self.running = False + + + def stop_stream(self): + try: + if self.stream and self.stream.is_active(): + self.stream.stop_stream() + self.stream.close() + self.running = False + except Exception as e: + st.error(f"Error stopping audio stream: {e}") + + def read_audio(self): + try: + return self.stream.read(self.frames_per_buffer, exception_on_overflow=False) + except IOError as e: + st.error(f"Error reading audio: {e}") + return None + + def vad_collector(self): + num_padding_chunks = int(self.padding_duration_ms / self.chunk_duration_ms) + ring_buffer = collections.deque(maxlen=num_padding_chunks) + triggered = False + voiced_frames = [] + segment_duration_ms = 0 + min_segment_duration_ms = self.min_segment_duration_s * 1000 + + while self.running: + frame = self.read_audio() + if frame is None: + continue + + is_speech = self.vad.is_speech(frame, self.sample_rate) + segment_duration_ms += self.chunk_duration_ms + + if not triggered: + ring_buffer.append((frame, is_speech)) + num_voiced = len([f for f, speech in ring_buffer if speech]) + if num_voiced > 0.9 * ring_buffer.maxlen: + triggered = True + voiced_frames.extend([f[0] for f in ring_buffer]) + ring_buffer.clear() + else: + voiced_frames.append(frame) + ring_buffer.append((frame, is_speech)) + num_unvoiced = len([f for f, speech in ring_buffer if not speech]) + + if num_unvoiced > 0.9 * ring_buffer.maxlen and segment_duration_ms >= min_segment_duration_ms: + triggered = False + if len(voiced_frames) > 0: # Only yield non-empty chunks + yield b''.join(voiced_frames) + ring_buffer.clear() + voiced_frames = [] + segment_duration_ms = 0 + + # Yield the final segment if it contains voiced frames + if voiced_frames: + yield b''.join(voiced_frames) diff --git a/examples/voice_transcription/utils/transcriber.py b/examples/voice_transcription/utils/transcriber.py new file mode 100644 index 00000000..250a6728 --- /dev/null +++ b/examples/voice_transcription/utils/transcriber.py @@ -0,0 +1,136 @@ +import os +import wave +import streamlit as st +from utils.segmenter import Segmenter +from nexa.gguf import NexaVoiceInference +from nexa.gguf import NexaTextInference # Import the text inference class +import logging + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +class RealTimeTranscriber: + def __init__(self, model_path, beam_size, task, temperature, output_directory="../transcriptions/audio/", verbose=False, language=None): + self.segmenter = Segmenter() + self.translation = task == "translate" + self.output_directory = output_directory + self.verbose = verbose + + # Initialize NexaVoiceInference with model and compute_type + if self.verbose: + logger.info(f"Loading model from {model_path}...") + self.inference = NexaVoiceInference( + model_path=model_path, + beam_size=beam_size, + language=language, + task=task, + temperature=temperature, + compute_type="default" + ) + if self.verbose: + st.write(f"Model loaded: {self.inference.downloaded_path}") + logger.info(f"Model loaded: {self.inference.downloaded_path}") + + def write_wav(self, filename, audio_data): + try: + with wave.open(filename, 'wb') as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(self.segmenter.sample_rate) + wf.writeframes(audio_data) + if self.verbose: + st.write(f"Saved {filename}") + return filename + except Exception as e: + st.error(f"Error writing WAV file: {e}") + logger.error(f"Error writing WAV file: {e}") + return None + + def transcribe_audio(self, audio_data): + try: + if self.verbose: + if self.translation: + st.write(f"Translating audio to English...") + else: + st.write(f"Transcribing audio...") + + # Transcribe directly from the audio data (in-memory) + segments, _ = self.inference.model.transcribe( + audio_data, + beam_size=self.inference.params["beam_size"], + language=self.inference.params["language"], + task=self.inference.params["task"], + temperature=self.inference.params["temperature"], + vad_filter=True + ) + transcription = "".join(segment.text for segment in segments) + return transcription + + except Exception as e: + logger.error(f"Transcription error: {e}") + return None + + def process_chunks(self, transcription_container): + self.segmenter.start_stream() + audio_chunks = self.segmenter.vad_collector() + + if not os.path.exists(self.output_directory): + os.makedirs(self.output_directory) + + for i, chunk in enumerate(audio_chunks): + if not self.segmenter.running: + break + + filename = f"{self.output_directory}/chunk_{i}.wav" + saved_filename = self.write_wav(filename, chunk) + self.last_transcription_set = False + if saved_filename: + transcription = self.transcribe_audio(saved_filename) + logger.info(f"Adding transcription: {i} {transcription}") + if transcription: + st.session_state["transcription"] += transcription + transcription_container.text_area("Transcription", value=st.session_state["transcription"], height=300) + + self.segmenter.stop_stream() + + def start_recording_foreground(self, transcription_container): + """Start recording in the foreground.""" + if "transcription" not in st.session_state: + st.session_state["transcription"] = "" + self.segmenter.running = True + st.session_state["recording_status"] = "Recording..." + self.process_chunks(transcription_container) + st.session_state["recording_status"] = "Recording completed" + + def stop_recording_foreground(self, transcription_container): + """Stop recording in the foreground.""" + self.segmenter.running = False + st.session_state["recording_status"] = "Recording stopped." + + def reset_transcription(self): + """Reset the transcription.""" + st.session_state["transcription"] = "" + st.session_state["recording_status"] = "Transcription reset." + +class TextInference: + def __init__(self, model_path="gemma", temperature=0.7, max_new_tokens=512, top_k=50, top_p=0.9): + # Initialize NexaTextInference with the specified parameters + try: + self.inference = NexaTextInference( + model_path=model_path, + stop_words=[], + temperature=temperature, + max_new_tokens=max_new_tokens, + top_k=top_k, + top_p=top_p, + profiling=False + ) + logger.info(f"Text model loaded: {self.inference.downloaded_path}") + except ValueError as e: + logger.warning(str(e)) + raise e + + def generate_summary(self, prompt): + for chunk in self.inference.create_completion(prompt, stream=True): + yield chunk["choices"][0]["text"] \ No newline at end of file From a698dfc6aaa7776661050b1cec22822de2c86f11 Mon Sep 17 00:00:00 2001 From: JoyboyBrian Date: Wed, 28 Aug 2024 03:31:04 +0000 Subject: [PATCH 3/4] rename, remove langchain dependency --- examples/financial-advisor/README.md | 3 +- examples/financial-advisor/app.py | 60 ++++---- .../utils/financial_analyzer.py | 111 +++++++++++++++ .../financial-advisor/utils/pdf_processor.py | 118 ---------------- .../financial-advisor/utils/text_generator.py | 130 ------------------ 5 files changed, 142 insertions(+), 280 deletions(-) create mode 100644 examples/financial-advisor/utils/financial_analyzer.py delete mode 100644 examples/financial-advisor/utils/pdf_processor.py delete mode 100644 examples/financial-advisor/utils/text_generator.py diff --git a/examples/financial-advisor/README.md b/examples/financial-advisor/README.md index 8af14ac8..1b222b87 100644 --- a/examples/financial-advisor/README.md +++ b/examples/financial-advisor/README.md @@ -12,7 +12,6 @@ - File structure: - `app.py`: main Streamlit application - - `utils/pdf_processor.py`: processes PDF files and creates embeddings - `utils/text_generator.py`: handles similarity search and text generation - `assets/fake_bank_statements`: fake bank statement for testing purpose @@ -27,7 +26,7 @@ pip install -r requirements.txt 2. Usage: - Run the Streamlit app: `streamlit run app.py` -- Upload PDF financial docs (bank statements, SEC filings, etc.) and process them. +- Upload PDF financial docs (bank statements, SEC filings, etc.) and process them - Use the chat interface to query your financial data ### Resources: diff --git a/examples/financial-advisor/app.py b/examples/financial-advisor/app.py index 2e9938bc..0043633b 100644 --- a/examples/financial-advisor/app.py +++ b/examples/financial-advisor/app.py @@ -1,9 +1,6 @@ import sys import os import streamlit as st -from typing import Iterator -import subprocess -import json import shutil import pdfplumber from sentence_transformers import SentenceTransformer @@ -12,8 +9,7 @@ import re import traceback import logging -from nexa.gguf import NexaTextInference -import utils.text_generator as tg +from utils.financial_analyzer import FinancialAnalyzer # set up logging: logging.basicConfig(level=logging.INFO) @@ -26,12 +22,10 @@ @st.cache_resource def load_model(model_path): - st.session_state.messages = [] - nexa_model = NexaTextInference(model_path) - return nexa_model + return FinancialAnalyzer(model_path) def generate_response(query: str) -> str: - result = tg.financial_analysis(query) + result = st.session_state.nexa_model.financial_analysis(query) if isinstance(result, dict) and "error" in result: return f"An error occurred: {result['error']}" return result @@ -59,7 +53,7 @@ def chunk_text(text, model, max_tokens=256, overlap=20): current_tokens = 0 for sentence in sentences: - sentence_tokens = len(model.tokenizer.tokenize(sentence)) + sentence_tokens = len(model.tokenize(sentence)) if current_tokens + sentence_tokens > max_tokens: if current_chunk: chunks.append(' '.join(current_chunk)) @@ -165,11 +159,11 @@ def process_pdfs(uploaded_files): # verify files were saved & reload the FAISS index: if os.path.exists(os.path.join(output_dir, 'pdf_index.faiss')) and \ - os.path.exists(os.path.join(output_dir, 'pdf_chunks.npy')): - # Reload the FAISS index - tg.embeddings, tg.index, tg.stored_docs = tg.load_faiss_index() - st.success("PDFs processed and FAISS index reloaded successfully!") - return True + os.path.exists(os.path.join(output_dir, 'pdf_chunks.npy')): + # Reload the FAISS index + st.session_state.nexa_model.load_faiss_index() + st.success("PDFs processed and FAISS index reloaded successfully!") + return True else: st.error("Error: Processed files not found after saving.") return False @@ -181,9 +175,11 @@ def process_pdfs(uploaded_files): return False def check_faiss_index(): - if tg.embeddings is None or tg.index is None or tg.stored_docs is None: - tg.embeddings, tg.index, tg.stored_docs = tg.load_faiss_index() - return tg.embeddings is not None and tg.index is not None and tg.stored_docs is not None + if "nexa_model" not in st.session_state: + return False + return (st.session_state.nexa_model.embeddings_model is not None and + st.session_state.nexa_model.index is not None and + st.session_state.nexa_model.stored_docs is not None) # Streamlit app: def main(): @@ -193,6 +189,9 @@ def main(): # add an empty line: st.markdown("
", unsafe_allow_html=True) + if "nexa_model" not in st.session_state: + st.session_state.nexa_model = load_model(default_model) + # check if FAISS index exists: if not check_faiss_index(): st.info("No processed financial documents found. Please upload and process PDFs.") @@ -220,24 +219,25 @@ def main(): st.warning("Please enter a valid path or identifier for the model in Nexa Model Hub to proceed.") st.stop() - if "current_model_path" not in st.session_state or st.session_state.current_model_path != model_path: + if "nexa_model" not in st.session_state or "current_model_path" not in st.session_state or st.session_state.current_model_path != model_path: st.session_state.current_model_path = model_path st.session_state.nexa_model = load_model(model_path) if st.session_state.nexa_model is None: st.stop() st.sidebar.header("Generation Parameters") - temperature = st.sidebar.slider("Temperature", 0.0, 1.0, st.session_state.nexa_model.params["temperature"]) - max_new_tokens = st.sidebar.slider("Max New Tokens", 1, 500, st.session_state.nexa_model.params["max_new_tokens"]) - top_k = st.sidebar.slider("Top K", 1, 100, st.session_state.nexa_model.params["top_k"]) - top_p = st.sidebar.slider("Top P", 0.0, 1.0, st.session_state.nexa_model.params["top_p"]) - - st.session_state.nexa_model.params.update({ - "temperature": temperature, - "max_new_tokens": max_new_tokens, - "top_k": top_k, - "top_p": top_p, - }) + params = st.session_state.nexa_model.get_params() + temperature = st.sidebar.slider("Temperature", 0.0, 1.0, params["temperature"]) + max_new_tokens = st.sidebar.slider("Max New Tokens", 1, 500, params["max_new_tokens"]) + top_k = st.sidebar.slider("Top K", 1, 100, params["top_k"]) + top_p = st.sidebar.slider("Top P", 0.0, 1.0, params["top_p"]) + + st.session_state.nexa_model.set_params( + temperature=temperature, + max_new_tokens=max_new_tokens, + top_k=top_k, + top_p=top_p + ) # step 3 - interactive financial analysis chat: st.header("Let's discuss your finances🧑‍💼") diff --git a/examples/financial-advisor/utils/financial_analyzer.py b/examples/financial-advisor/utils/financial_analyzer.py new file mode 100644 index 00000000..5723bd01 --- /dev/null +++ b/examples/financial-advisor/utils/financial_analyzer.py @@ -0,0 +1,111 @@ +import os +import faiss +import numpy as np +import logging +from nexa.gguf import NexaTextInference +from sentence_transformers import SentenceTransformer +from langchain_core.documents import Document + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +class FinancialAnalyzer: + def __init__(self, model_path="gemma"): + self.model_path = model_path + self.inference = NexaTextInference( + model_path=self.model_path, + stop_words=[], + temperature=0.7, + max_new_tokens=256, + top_k=50, + top_p=0.9, + profiling=False + ) + self.embeddings_model = SentenceTransformer('all-MiniLM-L6-v2') + self.index = None + self.stored_docs = None + self.load_faiss_index() + + def get_params(self): + return self.inference.params + + def set_params(self, **kwargs): + self.inference.params.update(kwargs) + + def load_faiss_index(self): + try: + faiss_index_dir = "./assets/output/processed_data" + if not os.path.exists(faiss_index_dir): + logger.warning(f"FAISS index directory not found: {faiss_index_dir}") + return + + index_file = os.path.join(faiss_index_dir, "pdf_index.faiss") + if not os.path.exists(index_file): + logger.warning(f"FAISS index file not found: {index_file}") + return + + self.index = faiss.read_index(index_file) + logger.info(f"FAISS index loaded successfully.") + + doc_file = os.path.join(faiss_index_dir, "pdf_chunks.npy") + self.stored_docs = np.load(doc_file, allow_pickle=True) + logger.info(f"Loaded {len(self.stored_docs)} documents") + + if not isinstance(self.stored_docs[0], Document): + self.stored_docs = [Document(page_content=doc) for doc in self.stored_docs] + + except Exception as e: + logger.error(f"Error loading FAISS index: {str(e)}") + + def custom_search(self, query, k=3): + if self.embeddings_model is None or self.index is None or self.stored_docs is None: + logger.error("FAISS index or embeddings model not properly loaded") + return [] + try: + query_vector = self.embeddings_model.encode([query])[0] + scores, indices = self.index.search(np.array([query_vector]), k) + docs = [self.stored_docs[i] for i in indices[0]] + return list(zip(docs, scores[0])) + except Exception as e: + logger.error(f"Error in custom_search: {str(e)}") + return [] + + def truncate_text(self, text, max_tokens=256): + tokens = text.split() + if len(tokens) <= max_tokens: + return text + return ' '.join(tokens[:max_tokens]) + + def financial_analysis(self, query): + try: + if self.embeddings_model is None or self.index is None or self.stored_docs is None: + logger.error("FAISS index not loaded. Please process PDF files first.") + return {"error": "FAISS index not loaded. Please process PDF files first."} + + relevant_docs = self.custom_search(query, k=1) + if not relevant_docs: + logger.warning("No relevant documents found for the query.") + return {"error": "No relevant documents found for the query."} + + context = "\n".join([doc.page_content for doc, _ in relevant_docs]) + truncated_context = self.truncate_text(context) + prompt = f"Financial context: {truncated_context}\n\nAnalyze: {query}" + + prompt_tokens = len(prompt.split()) + logger.info(f"Prompt length: {prompt_tokens} tokens") + + if prompt_tokens > 250: + prompt = self.truncate_text(prompt, 250) + logger.info(f"Truncated prompt length: {len(prompt.split())} tokens") + + llm_input = [ + {"role": "user", "content": prompt} + ] + + return self.inference.create_chat_completion(llm_input, stream=True) + + except Exception as e: + logger.error(f"Error in financial_analysis: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return {"error": str(e)} \ No newline at end of file diff --git a/examples/financial-advisor/utils/pdf_processor.py b/examples/financial-advisor/utils/pdf_processor.py deleted file mode 100644 index 4f6788cc..00000000 --- a/examples/financial-advisor/utils/pdf_processor.py +++ /dev/null @@ -1,118 +0,0 @@ -import warnings -warnings.filterwarnings("ignore", message=".*clean_up_tokenization_spaces.*") - -import os -import pdfplumber -from sentence_transformers import SentenceTransformer -import faiss -import numpy as np -import re - -input_dir = './assets/input/' -output_dir = './assets/output/processed_data/' - -# extract pdf file one by one: -def extract_text_from_pdf(pdf_path): - print(f"1️⃣ Extracting text from {pdf_path}") - with pdfplumber.open(pdf_path) as pdf: - text = '' - for i, page in enumerate(pdf.pages): - page_text = page.extract_text() - text += page_text + '\n' - print(f"Processed page {i+1}/{len(pdf.pages)}") - return text - -# chunk the file by tokens: -def chunk_text(text, model, max_tokens=256, overlap=20): - print("2️⃣ Chunking extracted text ...") - - # split the text into definitions and other parts - definitions = re.findall(r'"\w+(?:\s+\w+)*"\s+means.*?(?="\w+(?:\s+\w+)*"\s+means|\Z)', text, re.DOTALL) - other_parts = re.split(r'"\w+(?:\s+\w+)*"\s+means.*?(?="\w+(?:\s+\w+)*"\s+means|\Z)', text) - - chunks = [] - - for definition in definitions: - if len(model.tokenizer.tokenize(definition)) <= max_tokens: - chunks.append(definition.strip()) - else: - # if a definition is too long, split it into smaller parts - sentences = re.split(r'(?<=[.!?])\s+', definition) - current_chunk = [] - current_tokens = 0 - for sentence in sentences: - sentence_tokens = len(model.tokenizer.tokenize(sentence)) - if current_tokens + sentence_tokens > max_tokens: - chunks.append(' '.join(current_chunk).strip()) - current_chunk = [sentence] - current_tokens = sentence_tokens - else: - current_chunk.append(sentence) - current_tokens += sentence_tokens - if current_chunk: - chunks.append(' '.join(current_chunk).strip()) - - # process other parts - for part in other_parts: - if part.strip(): - sentences = re.split(r'(?<=[.!?])\s+', part) - current_chunk = [] - current_tokens = 0 - for sentence in sentences: - sentence_tokens = len(model.tokenizer.tokenize(sentence)) - if current_tokens + sentence_tokens > max_tokens: - chunks.append(' '.join(current_chunk).strip()) - current_chunk = [sentence] - current_tokens = sentence_tokens - else: - current_chunk.append(sentence) - current_tokens += sentence_tokens - if current_chunk: - chunks.append(' '.join(current_chunk).strip()) - - chunks = [chunk for chunk in chunks if chunk.strip()] # remove empty chunks, if any - - chunk_sizes = [len(model.tokenizer.tokenize(chunk)) for chunk in chunks] - print(f"👉 Created {len(chunks)} chunks") - print(f" Chunk sizes: min={min(chunk_sizes)}, max={max(chunk_sizes)}, avg={sum(chunk_sizes)/len(chunk_sizes):.1f}") - # print(f"👀{chunks}") - - return chunks - -# create embeddings for all chunks at once: -def create_embeddings(chunks, model): - print("3️⃣ Creating embeddings ...") - embeddings = model.encode(chunks) - print(f"👉 Created embeddings of shape: {embeddings.shape}") - return embeddings - -# add embeddings to FAISS index: -def build_faiss_index(embeddings): - print("4️⃣ Building FAISS index ...") - dimension = embeddings.shape[1] - index = faiss.IndexFlatL2(dimension) - index.add(embeddings.astype('float32')) - print(f"👉 Added {len(embeddings)} vectors to FAISS index") - return index - -model = SentenceTransformer('all-MiniLM-L6-v2') - -# process the pdf files: -all_chunks = [] -for filename in os.listdir(input_dir): - if filename.endswith('.pdf'): - pdf_path = os.path.join(input_dir, filename) - text = extract_text_from_pdf(pdf_path) - file_chunks = chunk_text(text, model) # using default overlap (20) - all_chunks.extend(file_chunks) - print(f" File: {filename}, Chunks: {len(file_chunks)}") -print(f"✅ Total chunks from all PDFs: {len(all_chunks)}") - -embeddings = create_embeddings(all_chunks, model) -faiss_index = build_faiss_index(embeddings) - -# save the index and chunks: -print("5️⃣ Saving FAISS index and chunks ...") -os.makedirs(output_dir, exist_ok=True) -faiss.write_index(faiss_index, os.path.join(output_dir, 'pdf_index.faiss')) -np.save(os.path.join(output_dir, 'pdf_chunks.npy'), all_chunks) diff --git a/examples/financial-advisor/utils/text_generator.py b/examples/financial-advisor/utils/text_generator.py deleted file mode 100644 index f792916e..00000000 --- a/examples/financial-advisor/utils/text_generator.py +++ /dev/null @@ -1,130 +0,0 @@ -import os -import faiss -import numpy as np -import logging -from nexa.gguf import NexaTextInference -from langchain_community.embeddings import HuggingFaceEmbeddings -from langchain_core.documents import Document - -# set up logging: -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# model initialization: -model_path = "gemma" -inference = NexaTextInference( - model_path=model_path, - stop_words=[], - temperature=0.7, - max_new_tokens=256, - top_k=50, - top_p=0.9, - profiling=False -) - -print(f"Model loaded: {inference.downloaded_path}") -print(f"Chat format: {inference.chat_format}") - -# global variables: -embeddings = None -index = None -stored_docs = None - -# load FAISS index: -def load_faiss_index(): - global embeddings, index, stored_docs - try: - embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2") - - faiss_index_dir = "./assets/output/processed_data" - - if not os.path.exists(faiss_index_dir): - logger.warning(f"FAISS index directory not found: {faiss_index_dir}") - return None, None, None - - index_file = os.path.join(faiss_index_dir, "pdf_index.faiss") - if not os.path.exists(index_file): - logger.warning(f"FAISS index file not found: {index_file}") - return None, None, None - - index = faiss.read_index(index_file) - logger.info(f"FAISS index loaded successfully.") - - # load the chunks: - doc_file = os.path.join(faiss_index_dir, "pdf_chunks.npy") - stored_docs = np.load(doc_file, allow_pickle=True) - logger.info(f"Loaded {len(stored_docs)} documents") - - # convert stored_docs to a list of Document objects: - if not isinstance(stored_docs[0], Document): - stored_docs = [Document(page_content=doc) for doc in stored_docs] - - return embeddings, index, stored_docs - - except Exception as e: - logger.error(f"Error loading FAISS index: {str(e)}") - return None, None, None - -# load the index at module level: -embeddings, index, stored_docs = load_faiss_index() - -def custom_search(query, k=3): - global embeddings, index, stored_docs - if embeddings is None or index is None or stored_docs is None: - logger.error("FAISS index or embeddings not properly loaded") - return [] - try: - query_vector = embeddings.embed_query(query) - scores, indices = index.search(np.array([query_vector]), k) - docs = [stored_docs[i] for i in indices[0]] - return list(zip(docs, scores[0])) - except Exception as e: - logger.error(f"Error in custom_search: {str(e)}") - return [] - -# truncate text to a specific token limit: -def truncate_text(text, max_tokens=256): - tokens = text.split() - if len(tokens) <= max_tokens: - return text - return ' '.join(tokens[:max_tokens]) - -# query FAISS and generate LLM response: -def financial_analysis(query): - global embeddings, index, stored_docs - try: - if embeddings is None or index is None or stored_docs is None: - logger.error("FAISS index not loaded. Please process PDF files first.") - return {"error": "FAISS index not loaded. Please process PDF files first."} - - relevant_docs = custom_search(query, k=1) - if not relevant_docs: - logger.warning("No relevant documents found for the query.") - return {"error": "No relevant documents found for the query."} - - context = "\n".join([doc.page_content for doc, _ in relevant_docs]) - - # truncate the context if it's too long: - truncated_context = truncate_text(context) - - prompt = f"Financial context: {truncated_context}\n\nAnalyze: {query}" - - prompt_tokens = len(prompt.split()) - logger.info(f"Prompt length: {prompt_tokens} tokens") - - if prompt_tokens > 250: - prompt = truncate_text(prompt, 250) - logger.info(f"Truncated prompt length: {len(prompt.split())} tokens") - - llm_input = [ - {"role": "user", "content": prompt} - ] - - # return the iterator - return inference.create_chat_completion(llm_input, stream=True) - - except Exception as e: - logger.error(f"Error in financial_analysis: {str(e)}") - import traceback - logger.error(traceback.format_exc()) - return {"error": str(e)} From 0a3cc5913b3ce6b4f1e2142cbd0c37a6832eb153 Mon Sep 17 00:00:00 2001 From: JoyboyBrian Date: Wed, 28 Aug 2024 04:09:28 +0000 Subject: [PATCH 4/4] add demo for `ai_soulmate` --- examples/ai_soulmate/README.md | 66 +++++++++ examples/ai_soulmate/bark_requirements.txt | 9 ++ examples/ai_soulmate/bark_voice_out/app.py | 120 +++++++++++++++ .../bark_voice_out/utils/gen_avatar.py | 27 ++++ .../bark_voice_out/utils/gen_response.py | 62 ++++++++ .../bark_voice_out/utils/initialize.py | 23 +++ .../bark_voice_out/utils/transcribe.py | 31 ++++ examples/ai_soulmate/openai_requirements.txt | 5 + examples/ai_soulmate/openai_voice_out/app.py | 139 ++++++++++++++++++ .../openai_voice_out/utils/gen_avatar.py | 27 ++++ .../openai_voice_out/utils/gen_response.py | 32 ++++ .../openai_voice_out/utils/initialize.py | 23 +++ .../openai_voice_out/utils/transcribe.py | 31 ++++ 13 files changed, 595 insertions(+) create mode 100644 examples/ai_soulmate/README.md create mode 100644 examples/ai_soulmate/bark_requirements.txt create mode 100644 examples/ai_soulmate/bark_voice_out/app.py create mode 100644 examples/ai_soulmate/bark_voice_out/utils/gen_avatar.py create mode 100644 examples/ai_soulmate/bark_voice_out/utils/gen_response.py create mode 100644 examples/ai_soulmate/bark_voice_out/utils/initialize.py create mode 100644 examples/ai_soulmate/bark_voice_out/utils/transcribe.py create mode 100644 examples/ai_soulmate/openai_requirements.txt create mode 100644 examples/ai_soulmate/openai_voice_out/app.py create mode 100644 examples/ai_soulmate/openai_voice_out/utils/gen_avatar.py create mode 100644 examples/ai_soulmate/openai_voice_out/utils/gen_response.py create mode 100644 examples/ai_soulmate/openai_voice_out/utils/initialize.py create mode 100644 examples/ai_soulmate/openai_voice_out/utils/transcribe.py diff --git a/examples/ai_soulmate/README.md b/examples/ai_soulmate/README.md new file mode 100644 index 00000000..130aafe8 --- /dev/null +++ b/examples/ai_soulmate/README.md @@ -0,0 +1,66 @@ +## NexaAI SDK Demo: AI Soulmate + +### Introduction: + +This project is an AI chatbot that interacts with users via text and voice. The project offers two options for voice output: using the **Bark** model for on-device text-to-speech or the **OpenAI TTS API** for cloud-based text-to-speech. **Bark** will be slow to generate speech without using GPU, but it's on device. The **OpenAI TTS API** has the advantage in terms of speed, but it is cloud-based and requires you to have an OPENAI API KEY. Each option is designed to provide flexibility based on the user's resources and preferences.You can also choose other options according to your preference. + +- Key features: + + - On-device Character AI + - No privacy concerns + +- File structure: + + - `bark_voice_out/app.py`: main Streamlit app using Bark for voice output + - `bark_voice_out/utils/initialize.py`: initializes chat and load model + - `bark_voice_out/utils/gen_avatar.py`: generates avatar for AI Soulmate + - `bark_voice_out/utils/transcribe.py`: handles voice input to text transcription + - `bark_voice_out/utils/gen_response.py`: handles text and voice output + + - `openai_voice_out/app.py`: main Streamlit app using OpenAI TTS API for voice output + - `openai_voice_out/utils/initialize.py`: initializes chat and load model + - `openai_voice_out/utils/gen_avatar.py`: generates avatar for AI Soulmate + - `openai_voice_out/utils/transcribe.py`: handles voice input to text transcription + - `openai_voice_out/utils/gen_response.py`: handles text and voice output + +### Technical Architecture + +

+ Technical Architecture +

+ +### Setup: + +#### Bark Voice Output + +1. Install required packages: + +``` +pip install -r bark_requirements.txt +``` + +2. Usage: + +- Run the Streamlit app: `streamlit run bark_voice_out/app.py` +- Start a chat with text or voice as you like + +#### OpenAI Voice Output + +1. Install required packages: + +``` +pip install -r openai_requirements.txt +``` + +2. Usage: + +- Add your openai key in utils/gen_response.py line 8 +- Run the Streamlit app: `streamlit run openai_voice_out/app.py` +- Start a chat with text or voice as you like + +### Resources: + +- [NexaAI | Model Hub](https://nexaai.com/models) +- [NexaAI | Inference with GGUF models](https://docs.nexaai.com/sdk/inference/gguf) +- [GitHub | BARK](https://github.com/suno-ai/bark) +- [Text to speech - OpenAI API](https://platform.openai.com/docs/guides/text-to-speech) diff --git a/examples/ai_soulmate/bark_requirements.txt b/examples/ai_soulmate/bark_requirements.txt new file mode 100644 index 00000000..5d159df2 --- /dev/null +++ b/examples/ai_soulmate/bark_requirements.txt @@ -0,0 +1,9 @@ +nexaai +sounddevice + +# Bark support +numpy==1.26 +git+https://github.com/suno-ai/bark.git + +# To use GPU: +torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu117 \ No newline at end of file diff --git a/examples/ai_soulmate/bark_voice_out/app.py b/examples/ai_soulmate/bark_voice_out/app.py new file mode 100644 index 00000000..6d23a8b0 --- /dev/null +++ b/examples/ai_soulmate/bark_voice_out/app.py @@ -0,0 +1,120 @@ +import streamlit as st +from utils.initialize import initialize_chat, load_model +from utils.gen_avatar import generate_ai_avatar +from utils.transcribe import record_and_transcribe +from utils.gen_response import generate_chat_response, generate_and_play_response + +ai_avatar = generate_ai_avatar() +default_model = "llama3-uncensored" + +def main(): + col1, col2 = st.columns([5,5], vertical_alignment = "center") + with col1: + st.title("AI Soulmate") + with col2: + st.image(ai_avatar, width=150) + st.caption("Powered by Nexa AI") + + st.sidebar.header("Model Configuration") + model_path = st.sidebar.text_input("Model path", default_model) + + if not model_path: + st.warning( + "Please enter a valid path or identifier for the model in Nexa Model Hub to proceed." + ) + st.stop() + + if ( + "current_model_path" not in st.session_state + or st.session_state.current_model_path != model_path + ): + st.session_state.current_model_path = model_path + st.session_state.nexa_model = load_model(model_path) + if st.session_state.nexa_model is None: + st.stop() + + st.sidebar.header("Generation Parameters") + temperature = st.sidebar.slider( + "Temperature", 0.0, 1.0, st.session_state.nexa_model.params["temperature"] + ) + max_new_tokens = st.sidebar.slider( + "Max New Tokens", 1, 1000, st.session_state.nexa_model.params["max_new_tokens"] + ) + top_k = st.sidebar.slider("Top K", 1, 100, st.session_state.nexa_model.params["top_k"]) + top_p = st.sidebar.slider( + "Top P", 0.0, 1.0, st.session_state.nexa_model.params["top_p"] + ) + + st.session_state.nexa_model.params.update( + { + "temperature": temperature, + "max_new_tokens": max_new_tokens, + "top_k": top_k, + "top_p": top_p, + } + ) + + initialize_chat() + for message in st.session_state.messages: + if message["role"] != "system": + if message["role"] == "user": + with st.chat_message(message["role"]): + st.markdown(message["content"]) + else: + with st.chat_message(message["role"], avatar=ai_avatar): + st.markdown(message["content"]) + + if st.button("🎙️ Start Voice Chat"): + transcribed_text = record_and_transcribe() + if transcribed_text: + st.session_state.messages.append({"role": "user", "content": transcribed_text}) + with st.chat_message("user"): + st.markdown(transcribed_text) + + with st.chat_message("assistant", avatar=ai_avatar): + response_placeholder = st.empty() + full_response = "" + for chunk in generate_chat_response(st.session_state.nexa_model): + choice = chunk["choices"][0] + if "delta" in choice: + delta = choice["delta"] + content = delta.get("content", "") + elif "text" in choice: + delta = choice["text"] + content = delta + + full_response += content + response_placeholder.markdown(full_response, unsafe_allow_html=True) + response_placeholder.markdown(full_response) + + generate_and_play_response(full_response) + + st.session_state.messages.append({"role": "assistant", "content": full_response}) + + if prompt := st.chat_input("Say something..."): + st.session_state.messages.append({"role": "user", "content": prompt}) + with st.chat_message("user"): + st.markdown(prompt) + + with st.chat_message("assistant", avatar=ai_avatar): + response_placeholder = st.empty() + full_response = "" + for chunk in generate_chat_response(st.session_state.nexa_model): + choice = chunk["choices"][0] + if "delta" in choice: + delta = choice["delta"] + content = delta.get("content", "") + elif "text" in choice: + delta = choice["text"] + content = delta + + full_response += content + response_placeholder.markdown(full_response, unsafe_allow_html=True) + response_placeholder.markdown(full_response) + + generate_and_play_response(full_response) + + st.session_state.messages.append({"role": "assistant", "content": full_response}) + +if __name__ == "__main__": + main() diff --git a/examples/ai_soulmate/bark_voice_out/utils/gen_avatar.py b/examples/ai_soulmate/bark_voice_out/utils/gen_avatar.py new file mode 100644 index 00000000..a6819cde --- /dev/null +++ b/examples/ai_soulmate/bark_voice_out/utils/gen_avatar.py @@ -0,0 +1,27 @@ +import streamlit as st +from nexa.gguf import NexaImageInference + +@st.cache_resource +def generate_ai_avatar(): + try: + image_model = NexaImageInference(model_path="lcm-dreamshaper", local_path=None) + + images = image_model.txt2img( + prompt="A girlfriend with long black hair", + cfg_scale=image_model.params["guidance_scale"], + width=image_model.params["width"], + height=image_model.params["height"], + sample_steps=image_model.params["num_inference_steps"], + seed=image_model.params["random_seed"], + ) + + if images and len(images) > 0: + avatar_path = "ai_avatar.png" + images[0].save(avatar_path) + return avatar_path + else: + st.error("No image was generated.") + return None + except Exception as e: + st.error(f"Error generating AI avatar: {str(e)}") + return None diff --git a/examples/ai_soulmate/bark_voice_out/utils/gen_response.py b/examples/ai_soulmate/bark_voice_out/utils/gen_response.py new file mode 100644 index 00000000..4e44473e --- /dev/null +++ b/examples/ai_soulmate/bark_voice_out/utils/gen_response.py @@ -0,0 +1,62 @@ +from typing import List, Iterator +import numpy as np +from nexa.gguf import NexaTextInference +from bark import SAMPLE_RATE, generate_audio, preload_models +from bark.api import semantic_to_waveform, generate_text_semantic +import streamlit as st +import sounddevice as sd + +def split_text(text: str, max_length: int = 200) -> List[str]: + words = text.split() + chunks = [] + chunk = [] + + for word in words: + if len(" ".join(chunk + [word])) > max_length: + chunks.append(" ".join(chunk)) + chunk = [word] + else: + chunk.append(word) + + if chunk: + chunks.append(" ".join(chunk)) + + return chunks + +def generate_and_play_response(response_text: str): + text_chunks = split_text(response_text) + + silence = np.zeros(int(0.25 * SAMPLE_RATE)) + GEN_TEMP = 0.6 + SPEAKER = "v2/en_speaker_9" + + pieces = [] + for sentence in text_chunks: + semantic_tokens = generate_text_semantic( + sentence, + history_prompt=SPEAKER, + temp=GEN_TEMP, + min_eos_p=0.05 + ) + audio_array = semantic_to_waveform(semantic_tokens, history_prompt=SPEAKER) + pieces.append(audio_array) + pieces.append(silence.copy()) + + combined_audio = np.concatenate(pieces) + play_audio(SAMPLE_RATE, combined_audio) + +def play_audio(sample_rate, audio_array): + sd.play(audio_array, sample_rate) + sd.wait() + +def generate_chat_response(nexa_model: NexaTextInference) -> Iterator: + messages = st.session_state.messages + response = nexa_model.create_chat_completion( + messages=messages, + temperature=nexa_model.params["temperature"], + max_tokens=nexa_model.params["max_new_tokens"], + top_k=nexa_model.params["top_k"], + top_p=nexa_model.params["top_p"], + stream=True + ) + return response diff --git a/examples/ai_soulmate/bark_voice_out/utils/initialize.py b/examples/ai_soulmate/bark_voice_out/utils/initialize.py new file mode 100644 index 00000000..ad215158 --- /dev/null +++ b/examples/ai_soulmate/bark_voice_out/utils/initialize.py @@ -0,0 +1,23 @@ +import streamlit as st +from nexa.gguf import NexaTextInference + +initial_prompt = """ +# You are Claudia, my perfect soul mate. You're empathetic, kind, and a great listener. Start by introuducing yourself briefly. You can't say more than 35 words in a sentence. +""" + +def initialize_chat(): + if "messages" not in st.session_state or not st.session_state.messages: + st.session_state.messages = [{"role": "system", "content": initial_prompt}] + +@st.cache_resource +def load_model(model_path): + st.session_state.messages = [] + nexa_model = NexaTextInference( + model_path=model_path, + local_path=None, + temperature=0.9, + max_new_tokens=256, + top_k=50, + top_p=1.0, + ) + return nexa_model \ No newline at end of file diff --git a/examples/ai_soulmate/bark_voice_out/utils/transcribe.py b/examples/ai_soulmate/bark_voice_out/utils/transcribe.py new file mode 100644 index 00000000..3a53a0b0 --- /dev/null +++ b/examples/ai_soulmate/bark_voice_out/utils/transcribe.py @@ -0,0 +1,31 @@ +import streamlit as st +import sounddevice as sd +from scipy.io.wavfile import write +from tempfile import NamedTemporaryFile +from nexa.gguf import NexaVoiceInference + +voice_model = NexaVoiceInference( + model_path="faster-whisper-base", + local_path=None, + beam_size=5, + task="transcribe", + temperature=0.0, + compute_type="default", +) + +def record_and_transcribe(duration=5, fs=16000): + info_placeholder = st.empty() + info_placeholder.info("Recording...") + + recording = sd.rec(int(duration * fs), samplerate=fs, channels=1) + sd.wait() + + info_placeholder.empty() + + with NamedTemporaryFile(delete=False, suffix=".wav") as f: + write(f.name, fs, recording) + audio_path = f.name + + segments, _ = voice_model.model.transcribe(audio_path) + transcription = "".join(segment.text for segment in segments) + return transcription diff --git a/examples/ai_soulmate/openai_requirements.txt b/examples/ai_soulmate/openai_requirements.txt new file mode 100644 index 00000000..4c909357 --- /dev/null +++ b/examples/ai_soulmate/openai_requirements.txt @@ -0,0 +1,5 @@ +nexaai +sounddevice + +# OpenAI API support +openai \ No newline at end of file diff --git a/examples/ai_soulmate/openai_voice_out/app.py b/examples/ai_soulmate/openai_voice_out/app.py new file mode 100644 index 00000000..fdb2d03f --- /dev/null +++ b/examples/ai_soulmate/openai_voice_out/app.py @@ -0,0 +1,139 @@ +import streamlit as st +import base64 +from utils.initialize import initialize_chat, load_model +from utils.gen_avatar import generate_ai_avatar +from utils.transcribe import record_and_transcribe +from utils.gen_response import generate_chat_response, generate_and_play_response + +ai_avatar = generate_ai_avatar() +default_model = "llama3-uncensored" + +def main(): + col1, col2 = st.columns([5,5], vertical_alignment = "center") + with col1: + st.title("AI Soulmate") + with col2: + st.image(ai_avatar, width=150) + st.caption("Powered by Nexa AI") + + st.sidebar.header("Model Configuration") + model_path = st.sidebar.text_input("Model path", default_model) + + if not model_path: + st.warning( + "Please enter a valid path or identifier for the model in Nexa Model Hub to proceed." + ) + st.stop() + + if ( + "current_model_path" not in st.session_state + or st.session_state.current_model_path != model_path + ): + st.session_state.current_model_path = model_path + st.session_state.nexa_model = load_model(model_path) + if st.session_state.nexa_model is None: + st.stop() + + st.sidebar.header("Generation Parameters") + temperature = st.sidebar.slider( + "Temperature", 0.0, 1.0, st.session_state.nexa_model.params["temperature"] + ) + max_new_tokens = st.sidebar.slider( + "Max New Tokens", 1, 1000, st.session_state.nexa_model.params["max_new_tokens"] + ) + top_k = st.sidebar.slider("Top K", 1, 100, st.session_state.nexa_model.params["top_k"]) + top_p = st.sidebar.slider( + "Top P", 0.0, 1.0, st.session_state.nexa_model.params["top_p"] + ) + + st.session_state.nexa_model.params.update( + { + "temperature": temperature, + "max_new_tokens": max_new_tokens, + "top_k": top_k, + "top_p": top_p, + } + ) + + initialize_chat() + for message in st.session_state.messages: + if message["role"] != "system": + if message["role"] == "user": + with st.chat_message(message["role"]): + st.markdown(message["content"]) + else: + with st.chat_message(message["role"], avatar=ai_avatar): + st.markdown(message["content"]) + + if st.button("🎙️ Start Voice Chat"): + transcribed_text = record_and_transcribe() + if transcribed_text: + st.session_state.messages.append({"role": "user", "content": transcribed_text}) + with st.chat_message("user"): + st.markdown(transcribed_text) + + with st.chat_message("assistant", avatar=ai_avatar): + response_placeholder = st.empty() + full_response = "" + for chunk in generate_chat_response(st.session_state.nexa_model): + choice = chunk["choices"][0] + if "delta" in choice: + delta = choice["delta"] + content = delta.get("content", "") + elif "text" in choice: + delta = choice["text"] + content = delta + + full_response += content + response_placeholder.markdown(full_response, unsafe_allow_html=True) + response_placeholder.markdown(full_response) + + audio_path = generate_and_play_response(full_response) + + with open(audio_path, "rb") as audio_file: + audio_bytes = audio_file.read() + audio_base64 = base64.b64encode(audio_bytes).decode("utf-8") + st.markdown(f""" + + """, unsafe_allow_html=True) + + st.session_state.messages.append({"role": "assistant", "content": full_response}) + + if prompt := st.chat_input("Say something..."): + st.session_state.messages.append({"role": "user", "content": prompt}) + with st.chat_message("user"): + st.markdown(prompt) + + with st.chat_message("assistant", avatar=ai_avatar): + response_placeholder = st.empty() + full_response = "" + for chunk in generate_chat_response(st.session_state.nexa_model): + choice = chunk["choices"][0] + if "delta" in choice: + delta = choice["delta"] + content = delta.get("content", "") + elif "text" in choice: + delta = choice["text"] + content = delta + + full_response += content + response_placeholder.markdown(full_response, unsafe_allow_html=True) + response_placeholder.markdown(full_response) + + audio_path = generate_and_play_response(full_response) + + with open(audio_path, "rb") as audio_file: + audio_bytes = audio_file.read() + audio_base64 = base64.b64encode(audio_bytes).decode("utf-8") + st.markdown(f""" + + """, unsafe_allow_html=True) + + st.session_state.messages.append({"role": "assistant", "content": full_response}) + +if __name__ == "__main__": + main() diff --git a/examples/ai_soulmate/openai_voice_out/utils/gen_avatar.py b/examples/ai_soulmate/openai_voice_out/utils/gen_avatar.py new file mode 100644 index 00000000..a6819cde --- /dev/null +++ b/examples/ai_soulmate/openai_voice_out/utils/gen_avatar.py @@ -0,0 +1,27 @@ +import streamlit as st +from nexa.gguf import NexaImageInference + +@st.cache_resource +def generate_ai_avatar(): + try: + image_model = NexaImageInference(model_path="lcm-dreamshaper", local_path=None) + + images = image_model.txt2img( + prompt="A girlfriend with long black hair", + cfg_scale=image_model.params["guidance_scale"], + width=image_model.params["width"], + height=image_model.params["height"], + sample_steps=image_model.params["num_inference_steps"], + seed=image_model.params["random_seed"], + ) + + if images and len(images) > 0: + avatar_path = "ai_avatar.png" + images[0].save(avatar_path) + return avatar_path + else: + st.error("No image was generated.") + return None + except Exception as e: + st.error(f"Error generating AI avatar: {str(e)}") + return None diff --git a/examples/ai_soulmate/openai_voice_out/utils/gen_response.py b/examples/ai_soulmate/openai_voice_out/utils/gen_response.py new file mode 100644 index 00000000..cfdd9fbd --- /dev/null +++ b/examples/ai_soulmate/openai_voice_out/utils/gen_response.py @@ -0,0 +1,32 @@ +import streamlit as st +from typing import Iterator +from typing import Iterator +from nexa.gguf import NexaTextInference +from pathlib import Path +from openai import OpenAI + +client = OpenAI(api_key="YOUR_OPENAI_API_KEY") + + +def generate_and_play_response(response_text: str): + speech_file_path = Path(__file__).parent / "speech.mp3" + response = client.audio.speech.create( + model="tts-1", + voice="shimmer", + input=response_text + ) + + response.stream_to_file(speech_file_path) + return str(speech_file_path) # Return the path as a string + +def generate_chat_response(nexa_model: NexaTextInference) -> Iterator: + messages = st.session_state.messages + response = nexa_model.create_chat_completion( + messages=messages, + temperature=nexa_model.params["temperature"], + max_tokens=nexa_model.params["max_new_tokens"], + top_k=nexa_model.params["top_k"], + top_p=nexa_model.params["top_p"], + stream=True + ) + return response diff --git a/examples/ai_soulmate/openai_voice_out/utils/initialize.py b/examples/ai_soulmate/openai_voice_out/utils/initialize.py new file mode 100644 index 00000000..e50314da --- /dev/null +++ b/examples/ai_soulmate/openai_voice_out/utils/initialize.py @@ -0,0 +1,23 @@ +import streamlit as st +from nexa.gguf import NexaTextInference + +initial_prompt = """ +# You are Claudia, my perfect soul mate. You're empathetic, kind, and a great listener. Start by introuducing yourself briefly. +""" + +def initialize_chat(): + if "messages" not in st.session_state or not st.session_state.messages: + st.session_state.messages = [{"role": "system", "content": initial_prompt}] + +@st.cache_resource +def load_model(model_path): + st.session_state.messages = [] + nexa_model = NexaTextInference( + model_path=model_path, + local_path=None, + temperature=0.9, + max_new_tokens=256, + top_k=50, + top_p=1.0, + ) + return nexa_model \ No newline at end of file diff --git a/examples/ai_soulmate/openai_voice_out/utils/transcribe.py b/examples/ai_soulmate/openai_voice_out/utils/transcribe.py new file mode 100644 index 00000000..f27c8b7c --- /dev/null +++ b/examples/ai_soulmate/openai_voice_out/utils/transcribe.py @@ -0,0 +1,31 @@ +import streamlit as st +import sounddevice as sd +from scipy.io.wavfile import write +from tempfile import NamedTemporaryFile +from nexa.gguf import NexaVoiceInference + +voice_model = NexaVoiceInference( + model_path="faster-whisper-tiny", + local_path=None, + beam_size=5, + task="transcribe", + temperature=0.0, + compute_type="default", +) + +def record_and_transcribe(duration=5, fs=16000): + info_placeholder = st.empty() + info_placeholder.info("Recording...") + + recording = sd.rec(int(duration * fs), samplerate=fs, channels=1) + sd.wait() + + info_placeholder.empty() + + with NamedTemporaryFile(delete=False, suffix=".wav") as f: + write(f.name, fs, recording) + audio_path = f.name + + segments, _ = voice_model.model.transcribe(audio_path) + transcription = "".join(segment.text for segment in segments) + return transcription