From 685586318f246a9a8c24eb0348ad40250f2fd247 Mon Sep 17 00:00:00 2001 From: charles Date: Mon, 25 May 2026 22:14:58 -0700 Subject: [PATCH] feat: implement core D&D helpers logic and system architecture --- .env | 5 + data/lore/Timeline.md | 1 + requirements.txt | 8 + src/__init__.py | 0 src/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 142 bytes src/llm/__init__.py | 1 + src/llm/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 146 bytes src/llm/__pycache__/models.cpython-314.pyc | Bin 0 -> 4035 bytes src/llm/__pycache__/processor.cpython-314.pyc | Bin 0 -> 4989 bytes src/llm/__pycache__/prompts.cpython-314.pyc | Bin 0 -> 1132 bytes src/llm/models.py | 56 ++++ src/llm/processor.py | 92 +++++++ src/llm/prompts.py | 20 ++ src/persistence/__init__.py | 1 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 154 bytes .../__pycache__/characters.cpython-314.pyc | Bin 0 -> 4187 bytes .../__pycache__/lore.cpython-314.pyc | Bin 0 -> 2462 bytes src/persistence/characters.py | 90 +++++++ src/persistence/lore.py | 55 ++++ .../__pycache__/orchestrator.cpython-314.pyc | Bin 0 -> 7275 bytes src/pipeline/orchestrator.py | 155 +++++++++++ src/stt/__init__.py | 1 + src/stt/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 146 bytes src/stt/__pycache__/listener.cpython-314.pyc | Bin 0 -> 4333 bytes .../__pycache__/transcriber.cpython-314.pyc | Bin 0 -> 2944 bytes src/stt/listener.py | 91 +++++++ src/stt/transcriber.py | 69 +++++ src/ui/__init__.py | 0 src/ui/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 145 bytes src/ui/__pycache__/cli.cpython-314.pyc | Bin 0 -> 2673 bytes src/ui/__pycache__/tui.cpython-314.pyc | Bin 0 -> 13402 bytes src/ui/cli.py | 55 ++++ src/ui/tui.py | 241 ++++++++++++++++++ .../test_persistence.cpython-314.pyc | Bin 0 -> 6339 bytes tests/test_llm.py | 61 +++++ tests/test_persistence.py | 135 ++++++++++ 36 files changed, 1137 insertions(+) create mode 100644 .env create mode 100644 data/lore/Timeline.md create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/__pycache__/__init__.cpython-314.pyc create mode 100644 src/llm/__init__.py create mode 100644 src/llm/__pycache__/__init__.cpython-314.pyc create mode 100644 src/llm/__pycache__/models.cpython-314.pyc create mode 100644 src/llm/__pycache__/processor.cpython-314.pyc create mode 100644 src/llm/__pycache__/prompts.cpython-314.pyc create mode 100644 src/llm/models.py create mode 100644 src/llm/processor.py create mode 100644 src/llm/prompts.py create mode 100644 src/persistence/__init__.py create mode 100644 src/persistence/__pycache__/__init__.cpython-314.pyc create mode 100644 src/persistence/__pycache__/characters.cpython-314.pyc create mode 100644 src/persistence/__pycache__/lore.cpython-314.pyc create mode 100644 src/persistence/characters.py create mode 100644 src/persistence/lore.py create mode 100644 src/pipeline/__pycache__/orchestrator.cpython-314.pyc create mode 100644 src/pipeline/orchestrator.py create mode 100644 src/stt/__init__.py create mode 100644 src/stt/__pycache__/__init__.cpython-314.pyc create mode 100644 src/stt/__pycache__/listener.cpython-314.pyc create mode 100644 src/stt/__pycache__/transcriber.cpython-314.pyc create mode 100644 src/stt/listener.py create mode 100644 src/stt/transcriber.py create mode 100644 src/ui/__init__.py create mode 100644 src/ui/__pycache__/__init__.cpython-314.pyc create mode 100644 src/ui/__pycache__/cli.cpython-314.pyc create mode 100644 src/ui/__pycache__/tui.cpython-314.pyc create mode 100644 src/ui/cli.py create mode 100644 src/ui/tui.py create mode 100644 tests/__pycache__/test_persistence.cpython-314.pyc create mode 100644 tests/test_llm.py create mode 100644 tests/test_persistence.py diff --git a/.env b/.env new file mode 100644 index 0000000..eb21030 --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +# D&D Helpers Configuration +OPENAI_API_KEY=your_api_key_here +LLM_MODEL=gpt-4o +WHISPER_MODEL=base +AUDIO_DEVICE_ID=None diff --git a/data/lore/Timeline.md b/data/lore/Timeline.md new file mode 100644 index 0000000..495f30b --- /dev/null +++ b/data/lore/Timeline.md @@ -0,0 +1 @@ +- The party defeated the goblins. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ab5a4a2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +# Core dependencies for D&D Helpers +faster-whisper +sounddevice +pydantic +textual +typer +openai +python-dotenv diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/__pycache__/__init__.cpython-314.pyc b/src/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ba30e24b6914ed2f786555830dfde5f335fae49a GIT binary patch literal 142 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 Z;OyGf-> str: + """ + Generic method to call the LLM. + """ + try: + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + response_format=response_format, + ) + return response.choices[0].message.content + except Exception as e: + print(f"LLM Error: {e}") + return "" + + def filter_transcript(self, text: str) -> str: + """ + Stage 1: Raw Transcript -> Filtered Text. + """ + return self._call_llm(NOISE_FILTER_SYSTEM_PROMPT, text) + + def extract_structured_data(self, filtered_text: str) -> ExtractionResult: + """ + Stage 2: Filtered Text -> Structured Data. + """ + # We use OpenAI's structured output (JSON mode/tool calling) via Pydantic's response_format. + # For models that support it, we can pass the Pydantic model directly. + # If we are using an older model or vLLM, we might need to manually parse the JSON. + + # Using the newer 'beta.chat.completions.parse' for Pydantic support + try: + completion = self.client.beta.chat.completions.parse( + model=self.model, + messages=[ + {"role": "system", "content": EXTRACTION_SYSTEM_PROMPT}, + {"role": "user", "content": filtered_text}, + ], + response_format=ExtractionResult, + ) + return completion.choices[0].message.parsed + except Exception as e: + print(f"Extraction Error: {e}") + # Return an empty ExtractionResult if parsing fails + return ExtractionResult() + + def process_pipeline(self, raw_text: str) -> ExtractionResult: + """ + Executes the two-stage pipeline: Raw Transcript -> Filtered Text -> Structured Data. + """ + filtered_text = self.filter_transcript(raw_text) + if not filtered_text: + return ExtractionResult() + + return self.extract_structured_data(filtered_text) diff --git a/src/llm/prompts.py b/src/llm/prompts.py new file mode 100644 index 0000000..42f9bf4 --- /dev/null +++ b/src/llm/prompts.py @@ -0,0 +1,20 @@ +# System prompts for the LLM pipeline + +NOISE_FILTER_SYSTEM_PROMPT = """ +You are a D&D Game Master's assistant. Given a transcript, remove all out-of-character (OOC) chatter, logistical discussions (e.g., 'Where is my d20?'), and non-relevant noise. +Output only the in-character dialogue and game-relevant events. +Keep the original speakers' names if they are present in the transcript. +Do not add any commentary or summaries. Just filter the text. +""" + +EXTRACTION_SYSTEM_PROMPT = """ +You are a D&D session analyzer. Your goal is to extract structured data from a filtered transcript. +Extract any changes to character states (HP, status effects, inventory) and any new lore facts (NPCs, locations, world-building). + +Guidelines: +1. Lore: Identify any new information about the world, people, and places. +2. Character State: Look for mentions of damage, healing, or items being gained or lost. +3. Events: Note significant plot developments. + +Be precise. If no relevant information is found, return empty lists. +""" diff --git a/src/persistence/__init__.py b/src/persistence/__init__.py new file mode 100644 index 0000000..8794b9e --- /dev/null +++ b/src/persistence/__init__.py @@ -0,0 +1 @@ +# Persistence module for D&D Helpers. diff --git a/src/persistence/__pycache__/__init__.cpython-314.pyc b/src/persistence/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4c10d38788c038e1d692f60b6ba2d018a30c8957 GIT binary patch literal 154 zcmdPqlIYq;;_lh cPbtkwwJTx;ngg<_7{vI*%*e=C#0+Es061YI9RL6T literal 0 HcmV?d00001 diff --git a/src/persistence/__pycache__/characters.cpython-314.pyc b/src/persistence/__pycache__/characters.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c5d01df7ffa75ddd43ae5064d8daee95eeb71673 GIT binary patch literal 4187 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 literal 0 HcmV?d00001 diff --git a/src/persistence/characters.py b/src/persistence/characters.py new file mode 100644 index 0000000..45455be --- /dev/null +++ b/src/persistence/characters.py @@ -0,0 +1,90 @@ +import json +from pathlib import Path +from typing import Any, Dict, Optional + +from src.llm.models import CharacterStateUpdate + +DATA_CHARS_DIR = Path("data/chars") + + +def ensure_chars_dir(): + """Ensures the character data directory exists.""" + DATA_CHARS_DIR.mkdir(parents=True, exist_ok=True) + + +def get_character_state(character_name: str) -> Dict[str, Any]: + """ + Reads character state from a JSON file. + If the character doesn't exist, returns a default state. + """ + ensure_chars_dir() + file_path = DATA_CHARS_DIR / f"{character_name}.json" + + if not file_path.exists(): + return { + "character_name": character_name, + "stats": {"hp": 0, "max_hp": 0, "ac": 0}, + "status_effects": [], + "inventory": [], + } + + with open(file_path, "r", encoding="utf-8") as f: + return json.load(f) + + +def update_character_state(update: CharacterStateUpdate): + """ + Updates character state based on a CharacterStateUpdate model. + Reads the current state, applies changes, and writes it back. + """ + ensure_chars_dir() + + state = get_character_state(update.character_name) + + # Update HP + if update.hp_change is not None: + current_hp = state.get("stats", {}).get("hp", 0) + state["stats"]["hp"] = current_hp + update.hp_change + + # Update Status Effects + status_effects = set(state.get("status_effects", [])) + for effect in update.status_effects_added: + status_effects.add(effect) + for effect in update.status_effects_removed: + status_effects.discard(effect) + state["status_effects"] = list(status_effects) + + # Update Inventory + inventory = state.get("inventory", []) + for change in update.inventory_changes: + # Find if item already exists + item_exists = False + for item in inventory: + if item["item"] == change.item: + if change.action == "added": + item["quantity"] = item.get("quantity", 1) + change.quantity + elif change.action == "removed": + item["quantity"] = max(0, item.get("quantity", 1) - change.quantity) + item_exists = True + break + + if not item_exists: + if change.action == "added": + inventory.append( + { + "item": change.item, + "quantity": change.quantity, + "weight": 0, # Default weight if not provided + } + ) + elif change.action == "removed": + # Item not in inventory, do nothing or log + pass + + state["inventory"] = inventory + + file_path = DATA_CHARS_DIR / f"{update.character_name}.json" + with open(file_path, "w", encoding="utf-8") as f: + json.dump(state, f, indent=4) + + return str(file_path) diff --git a/src/persistence/lore.py b/src/persistence/lore.py new file mode 100644 index 0000000..b35dbef --- /dev/null +++ b/src/persistence/lore.py @@ -0,0 +1,55 @@ +import os +from pathlib import Path +from typing import Optional + +from src.llm.models import LoreUpdate + +DATA_LORE_DIR = Path("data/lore") + +CATEGORY_MAP = { + "NPC": "NPCs", + "Location": "Locations", + "WorldBuilding": "World", + "Plot": "Timeline", +} + + +def ensure_lore_dir(): + """Ensures the lore data directory and subdirectories exist.""" + DATA_LORE_DIR.mkdir(parents=True, exist_ok=True) + for folder in CATEGORY_MAP.values(): + if folder != "Timeline": + (DATA_LORE_DIR / folder).mkdir(parents=True, exist_ok=True) + + +def update_lore(update: LoreUpdate): + """ + Updates lore based on a LoreUpdate model. + - For NPC/Location/WorldBuilding: Updates the specific entity's file. + - For Plot: Appends to Timeline.md. + """ + ensure_lore_dir() + + category = update.category + folder = CATEGORY_MAP.get(category, "Other") + + if category == "Plot": + file_path = DATA_LORE_DIR / "Timeline.md" + content_to_append = f"- {update.content}\n" + elif update.entity_name: + category_folder = CATEGORY_MAP.get(category, "Other") + file_path = DATA_LORE_DIR / category_folder / f"{update.entity_name}.md" + # For entity-specific files, we can append the content as a list item. + # In a more complex system, we might check for existing headers. + content_to_append = f"- {update.content}\n" + else: + # Fallback if no entity name is provided for a category that expects one. + # We'll put it in a general file for that category. + category_folder = CATEGORY_MAP.get(category, "Other") + file_path = DATA_LORE_DIR / category_folder / "General.md" + content_to_append = f"- {update.content}\n" + + with open(file_path, "a", encoding="utf-8") as f: + f.write(content_to_append) + + return str(file_path) diff --git a/src/pipeline/__pycache__/orchestrator.cpython-314.pyc b/src/pipeline/__pycache__/orchestrator.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..deb8141a776389e046dffcaf75d8535483e5909e GIT binary patch literal 7275 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 it}jXFs@YjEd+(aPZy#JXx6K Text. + """ + logger.info("STT Worker started.") + while self.is_running: + try: + # Get audio chunk from listener + audio_chunk = await self.listener.get_chunk() + + # Transcribe + text = self.transcriber.transcribe(audio_chunk) + + if text: + logger.info(f"Transcribed: {text}") + await self.transcript_queue.put(text) + + except Exception as e: + logger.error(f"STT Worker error: {e}") + + # Small sleep to prevent tight loop if get_chunk is fast + await asyncio.sleep(0.1) + + async def llm_worker(self): + """ + Worker that handles LLM: Text -> Proposal. + """ + logger.info("LLM Worker started.") + while self.is_running: + try: + # Get raw text from transcript queue + raw_text = await self.transcript_queue.get() + + logger.info(f"Processing text: {raw_text}") + + # Process via LLM (Filter -> Extract) + result = self.processor.process_pipeline(raw_text) + + if ( + result.lore_updates + or result.character_updates + or result.significant_events + ): + logger.info("Proposal generated. Putting into proposal queue.") + await self.proposal_queue.put(result) + else: + logger.info("No relevant game data extracted.") + + except Exception as e: + logger.error(f"LLM Worker error: {e}") + + # Small sleep + await asyncio.sleep(0.1) + + async def tui_worker(self): + """ + Worker that handles TUI: Proposal -> Persistence. + """ + logger.info("TUI Worker started.") + while self.is_running: + try: + # Get proposal from queue + result = await self.proposal_queue.get() + + logger.info("Proposal received. Launching TUI for confirmation.") + + # Launch TUI (Note: Textual's run() is blocking) + # We need to run the TUI in a way that doesn't block the overall event loop + # or we accept that the system pauses for confirmation. + # Given the requirement for "Non-blocking", but TUI is a focus-modal, + # we launch it. + + # To integrate Textual with asyncio, we can use its async support. + # However, ConfirmationApp is designed as a standard Textual app. + # Since we want to bridge the asyncio loop, we'll run the TUI. + + # Note: In a real high-performance pipeline, we'd use an async TUI + # that updates widgets in real-time. For now, we follow the + # a confirmation screen pattern. + + # we will use the run() method, but since we are in an async loop, + # we might need to wrap it or use an async variant. + # For this integration, we'll use the run() method as defined + # in ConfirmationApp, which will take over the terminal. + + ConfirmationApp(result).run() + + except Exception as e: + logger.error(f"TUI Worker error: {e}") + + # Small sleep + await asyncio.sleep(0.1) + + async def run(self): + """ + Starts the pipeline workers and the audio listener. + """ + self.is_running = True + self.listener.start() + + # Start workers as background tasks + tasks = [ + asyncio.create_task(self.stt_worker()), + asyncio.create_task(self.llm_worker()), + asyncio.create_task(self.tui_worker()), + ] + + try: + # Keep the loop running + while self.is_running: + await asyncio.sleep(1) + except asyncio.CancelledError: + pass + finally: + self.is_running = False + self.listener.stop() + for task in tasks: + task.cancel() + + # Wait for tasks to complete + await asyncio.gather(*tasks, return_exceptions=True) + + def stop(self): + """ + Stops the pipeline. + """ + self.is_running = False diff --git a/src/stt/__init__.py b/src/stt/__init__.py new file mode 100644 index 0000000..6b633f7 --- /dev/null +++ b/src/stt/__init__.py @@ -0,0 +1 @@ +# STT Module diff --git a/src/stt/__pycache__/__init__.cpython-314.pyc b/src/stt/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ffdeb6724422971f2fcc4307836b30f8b91a3b45 GIT binary patch literal 146 zcmdPqTZlX-=wL W5i3wX$cADN;}bI@BV!RWkOctjlpoaq literal 0 HcmV?d00001 diff --git a/src/stt/__pycache__/listener.cpython-314.pyc b/src/stt/__pycache__/listener.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..30fb0aa9504001757e9c0620142ad2d848160f55 GIT binary patch literal 4333 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 literal 0 HcmV?d00001 diff --git a/src/stt/__pycache__/transcriber.cpython-314.pyc b/src/stt/__pycache__/transcriber.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..16fe8f74fbf2c7e87555148c9ac0d9b99042a571 GIT binary patch literal 2944 zcmai0O>Er86(0WXA0_L@`n6VKB>!k@h1viC7e*3eC43NwiDQJfL3iA)?q@1q5^ zuHG15<8nYd+Dz%ZP?beHqmvKoz)p3dro&=g=rv@@J;+oh#Ht>Po5BdnsQ={4`HT>1 z?=8Eugvl?0JoaF%c=$Hi3BEQ^^6i{}tam?|6^4^xZ8_NhbMTbNX?CVw9Iw=rQXWbg)r0#`Iz4 z`m4h*-m)p?BVDmQZ#ZK_n0b8Lbq?XxBmhFneiLfo>!ZU?sXVL)L?3p_dAp9`1S_R7 z!xmdD9YU(f^+ZK(q{f{+} z^tY~G{`mU7ids7F&EB2e>Yeyfd-LU2$9ngX2#Q+Ay}7${TPNP$Xy1^w`YwN=U3spx z|9e+~mp{-RIQb7}E+x@sGIi;kwAs~n$&fY;1?Enjk3a>*;DKw@_B@FGHIx$yuo{4* z9)a}D2@5qU%O7%o4i8-{;j8;=wI7s{Y9)xn@O~sVY*R(%^kxZD%0zdyq%SmnM zU8O7Sh7W zoQ!BDw#1zpwT1TjtTCGEV(c)@q=DH>v5Ua$Behu*;lwJxOEv-rI2=gBFM7C{%nvb5 zMDkWd^1$&W!_P+)Ut^qV3CP>b)@=^fR0IIU#H>vG8f5`ZRavSL(U5ykA&lgFh{9Nu z{^!P_m?dsudnF>bP|zz~b#tRM)1xe#(ld!rg^~g^L->`2$p&gDe-OBSD0A#kfn(hg zX#pL=3oqf9nTeBQd{e6FFk&3H#U2p4qV^voL#^CqO<4IsZXYUe_=}-`qy^~O3w-}A*b8=cougbWbuz5wLgL^ zp7=t0^Y5KqQ5JXqKr>+I`o(DlZN`SDQG7Gg4f7{R;k}ZYJ|{hC>zy{FCx!xZr#V;o z95Pwk<9|nM5Pk6fAXdhcSMHQN*KwJ*suS#Cs6JIDhZPOV1V~wT3%ZXvJKJOxBH~Xd zBScQ39P@N^#i8PO0mWf^^Cd^3i=ab!Qm>(JHI!)CgDD5#p)sG4LYWu5P=b=hIh;*q zlAK6=dj$iLCM^rNy6o|}W?3JWZLjv`m}M1QLYe3K*bm@!%(C)OmxsGKsh__o%0x77sTbAS5l;&NBIoR_o3!=NFNeJ8c=e=ExAbx3tcf3?k;1cxm zxBQDlQHk%zJC%;_e$=mYe?O@zExQOhGFsmN*%0G33Q{?2t^R6RwO_4jxk|wWxDNce z72IMd&&*$aCrlJEn~Uli8RIxnc0?we;>!q|2X+|0P`#d?k)Obl+e^= self.chunk_duration: + # Concatenate all buffers into one chunk + chunk = np.concatenate(self._buffer, axis=0) + # Trim to exactly chunk_duration to maintain consistency + target_samples = int(self.sample_rate * self.chunk_duration) + chunk = chunk[:target_samples] + + # Use call_soon_threadsafe to put the chunk into the asyncio queue from the callback thread + self.loop.call_soon_threadsafe(self.audio_queue.put_nowait, chunk) + self._buffer = [] + + def start(self): + """ + Starts the audio capture stream. + """ + if self.loop is None: + raise RuntimeError("Event loop must be provided to AudioListener") + + self.is_listening = True + self._buffer = [] + + # Define the block size for the callback + # We'll use a smaller block size (e.g. 0.1s) to keep the callback responsive + block_size = int(self.sample_rate * 0.1) + + try: + self.stream = sd.InputStream( + device=self.device, + channels=1, + samplerate=self.sample_rate, + blocksize=block_size, + callback=self._audio_callback, + ) + self.stream.start() + logger.info("Audio listener started.") + except Exception as e: + logger.error(f"Failed to start audio listener: {e}") + self.is_listening = False + raise + + def stop(self): + """ + Stops the audio capture stream. + """ + if hasattr(self, "stream"): + self.stream.stop() + self.stream.close() + self.is_listening = False + logger.info("Audio listener stopped.") + + async def get_chunk(self): + """ + Retrieves a chunk of audio from the queue asynchronously. + """ + return await self.audio_queue.get() diff --git a/src/stt/transcriber.py b/src/stt/transcriber.py new file mode 100644 index 0000000..ce96c33 --- /dev/null +++ b/src/stt/transcriber.py @@ -0,0 +1,69 @@ +import logging + +from faster_whisper import WhisperModel + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class Transcriber: + """ + Converts audio chunks (numpy arrays) into text using faster-whisper. + """ + + def __init__(self, model_size="base", device="cpu", compute_type="int8"): + """ + Initializes the faster-whisper model. + + Args: + model_size (str): The size of the model to use (e.g., "tiny", "base", "small"). + device (str): The device to run the model on ("cpu" or "cuda"). + compute_type (str): The compute type to use (e.g., "int8", "float16"). + """ + logger.info( + f"Loading faster-whisper model: {model_size} on {device} ({compute_type})..." + ) + try: + self.model = WhisperModel( + model_size, device=device, compute_type=compute_type + ) + logger.info("Model loaded successfully.") + except Exception as e: + logger.error(f"Failed to load faster-whisper model: {e}") + raise + + def transcribe(self, audio_chunk): + """ + Transcribes a single audio chunk. + + Args: + audio_chunk (np.ndarray): The audio data as a numpy array. + + Returns: + str: The transcribed text. + """ + if audio_chunk is None: + return "" + + try: + # faster-whisper expects audio in float32 + audio_data = audio_chunk.astype("float32") + + # Transcribe the audio + segments, info = self.model.transcribe(audio_data, beam_size=5) + + # Combine segments into a single string + text = " ".join([segment.text.strip() for segment in segments]) + + return text.strip() + except Exception as e: + logger.error(f"Transcription error: {e}") + return "" + + def close(self): + """ + Explicitly release model resources if necessary. + """ + # faster-whisper's WhisperModel doesn't have a standard close(), + # but we'll provide this for consistency. + pass diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/__pycache__/__init__.cpython-314.pyc b/src/ui/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7db42bd149ab40cca0598128e4766747ab8faa19 GIT binary patch literal 145 zcmdPq_I|p@<2{{|u76rKg{fpPQ2KczG$)vkyY Us2yZMF^KVrnURsPh#ANN0J?7 literal 0 HcmV?d00001 diff --git a/src/ui/__pycache__/cli.cpython-314.pyc b/src/ui/__pycache__/cli.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cd8499489290961e0c0481cd298dec18d7e87ff4 GIT binary patch literal 2673 zcmai0-ESMm5uf9I@c1Q4A{E7!6`rg>?Kusp$OiJ|7*HiUq9ZHJMV5_#_JJiY;w8ts z!`?E9_{5}5?3VyVQ6NPh+8_uJ^`+10WBV6KHDNi|7)TKVMc$}NNZLGQ_Q)fN!fhA0 z-PxJh-PxJn%-$1YsW@P}rB1!EF9LkcCSjBN#8I6CXv0m=xFS$CMvB~8ghp~b9#47X z@kL=xq~e-HrL`!H1~Q>2uf=FApv7W*EkP4GxWShm4UZU(bD&9D^l3baHJZASD$Vxj z(f(Y2qRG<`(lq_Qb@4o9bdMg5=f$)*o*3!PQS0Q$ww@yf!usT6!9+ zaf=(+Ie8Ja3oR~-9wlQb%9 z<+AIx(yI%r%9>%h2Jw`Y^&&gDj??O>J0zU&0K$n#DdGsz!sDep=>yAai!F77E+!6sLl2)V;CJPqX!wQ=P3lfzXZHHR4#o*j-kn1%J%On9E31(Q?7)Y>TX}Z)G*cra)S%%@N5yIl=C!G;cdJIB};;V(a(=ZBEM7w2p1-DSMYfmyHA}|Xc zsTP`Mp=z0VcZaY*1RaaNcN@Dl9LGV7-@5$X<-PP%9bxG~V)7s{_t(VSoy2^5^~=nU zb~ihjrS^JP5X7Z>a`HgV?aR53lLyM;zOr~nS?b7VN47Y9SD63TaSW2P#}Hu)vIp{s zefdO3o*69uH2PWUlhkhIj*PUD6NTOK6rPfntGziE*nU*m_ME%5W83g56bevFx}Q{x@# z$5@bbW~?8pyL!(il_z*2_&gRHpEqrac$4rhSZX|G>S^)_6DM`OV%rWYWx7rjWX<8{ zox!dR-@znz`jB;BrZc}@`EaMpBmE#f*%fg-mS8+{7)9y`wV}$63mdB)L52Dw5^by) z<8YO4M)J^%Ttc|Gd^vO%Rwpg8#cTZ2{q-A>mofk`cnn+t^#;3Mo79BxeaLgJSjHE! z*REaE#8Bc&WtI~`+mga8E)8l~D1*^zFU_M}Is9DS1U1@vGQNp5I!N*ANPSi|uNIZJ zOd(YN?M$W?UZwnKLO1ZJs8cY1+|;zkr1YQ!I7;R;@mKtxWV zO~_6L0S&P@F;wcaHdK)?Y!QKV?DB;D69kE;MhSxtnPDB<&leanh|0)h0FQ8Ei3IPH z$Go(#u%KY;A%R)Qo+6S*iGrVZ6T3DXJ_=Kh9f^Ea`g?Zv_UbVYvCMBS?!NUoJ&;$w z$j){}5GT5T@?#mM)X!s`?6tkj_4fLM)a>s0PU?F55+X&+{_@ftVdg-{?F+fP!rZ_A znZXqvyT<#|%aejH>pCl^x~^sktM|Ty1rW<;fMSwloG~mrdCO|#8&1uzyb(t*zxXGZ zcu;{yYg(^N#+kfn=4sOmJm-s>L5bxnuIuCN8;y!x3*70;%VfLRz;OA}%PxDG1E(+> zS(fSgAAqnl={Zh5kM=Yz;{tgec?{Ry@38Cg9LN0)#_q%P*O2@NT-k>!_u;wwu<(_X zxb^0HZ|+V1xFe};zAIr(yEXIf%+F^JBWdpOBOW5@E~C1S4V0w1XfRMc1>hLGN5TYMkJmf(* H8QA{=hmu1m literal 0 HcmV?d00001 diff --git a/src/ui/__pycache__/tui.cpython-314.pyc b/src/ui/__pycache__/tui.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..057531bc9d4d226426d682fa6cdcf13939fadaa9 GIT binary patch literal 13402 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 ComposeResult: + yield Container( + Horizontal( + Vertical( + DataTable(id="update-table"), + id="left-pane", + ), + Vertical( + Vertical( + Label("Details:", id="details-label"), + Static("No update selected", id="details-text"), + id="details-container", + ), + Vertical( + Label("Edit Value:"), + Input(id="edit-input"), + Button("Save Edit", id="save-edit"), + id="edit-container", + ), + Horizontal( + Button("Accept", id="btn-accept"), + Button("Reject", id="btn-reject"), + Button("Edit", id="btn-edit"), + id="actions-container", + ), + id="right-pane", + ), + ), + Footer(), + ) + + def on_mount(self) -> None: + table = self.query_one("#update-table", DataTable) + table.add_columns("Type", "Target", "Update") + + for i, update in enumerate(self.pending_updates): + if isinstance(update, LoreUpdate): + table.add_row( + "Lore", update.entity_name or "General", update.content, key=str(i) + ) + elif isinstance(update, CharacterStateUpdate): + change_text = f"HP: {update.hp_change or 0}" + if update.status_effects_added: + change_text += f", Added: {', '.join(update.status_effects_added)}" + if update.status_effects_removed: + change_text += ( + f", Removed: {', '.join(update.status_effects_removed)}" + ) + table.add_row("Char", update.character_name, change_text, key=str(i)) + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + self.selected_index = event.row + update = self.pending_updates[self.selected_index] + + details_text = self.query_one("#details-text", Static) + if isinstance(update, LoreUpdate): + details_text.update( + f"Category: {update.category}\nTarget: {update.entity_name}\nContent: {update.content}" + ) + elif isinstance(update, CharacterStateUpdate): + details_text.update( + f"Character: {update.character_name}\nHP Change: {update.hp_change}\nAdded Effects: {update.status_effects_added}\nRemoved Effects: {update.status_effects_removed}" + ) + + # Reset to detail view + self.query_one("#edit-container", Vertical).styles.display = "none" + self.query_one("#details-container", Vertical).styles.display = "block" + + def on_button_clicked(self, event: Button.Clicked) -> None: + if self.selected_index == -1: + return + + update = self.pending_updates[self.selected_index] + + if event.button.id == "btn-accept": + if isinstance(update, LoreUpdate): + update_lore(update) + elif isinstance(update, CharacterStateUpdate): + update_character_state(update) + + self.remove_update(self.selected_index) + + elif event.button.id == "btn-reject": + self.remove_update(self.selected_index) + + elif event.button.id == "btn-edit": + self.show_edit_mode(update) + + elif event.button.id == "save-edit": + self.save_edit(update) + + def show_edit_mode(self, update: Union[LoreUpdate, CharacterStateUpdate]) -> None: + edit_input = self.query_one("#edit-input", Input) + + if isinstance(update, LoreUpdate): + edit_input.value = update.content + elif isinstance(update, CharacterStateUpdate): + # For simplicity, only allow editing HP change in this TUI + edit_input.value = str(update.hp_change or 0) + + self.query_one("#edit-container", Vertical).styles.display = "block" + self.query_one("#details-container", Vertical).styles.display = "none" + + def save_edit(self, update: Union[LoreUpdate, CharacterStateUpdate]) -> None: + new_val = self.query_one("#edit-input", Input).value + + if isinstance(update, LoreUpdate): + update.content = new_val + elif isinstance(update, CharacterStateUpdate): + try: + update.hp_change = int(new_val) + except ValueError: + # Ignore invalid integer input + pass + + # Refresh the table + table = self.query_one("#update-table", DataTable) + # Textual DataTable doesn't have a simple 'update_row', so we clear and refill + # or we can use update_cell. + + # Update the table row + if isinstance(update, LoreUpdate): + table.update_cell(self.selected_index, 2, update.content) + elif isinstance(update, CharacterStateUpdate): + change_text = f"HP: {update.hp_change or 0}" + if update.status_effects_added: + change_text += f", Added: {', '.join(update.status_effects_added)}" + if update.status_effects_removed: + change_text += f", Removed: {', '.join(update.status_effects_removed)}" + table.update_cell(self.selected_index, 2, change_text) + + self.show_edit_mode(update) # just to refresh the value maybe? No,’ + # Actually let's go back to detail view + self.query_one("#edit-container", Vertical).styles.display = "none" + self.query_one("#details-container", Vertical).styles.display = "block" + + # Update details text + details_text = self.query_one("#details-text", Static) + if isinstance(update, LoreUpdate): + details_text.update( + f"Category: {update.category}\nTarget: {update.entity_name}\nContent: {update.content}" + ) + elif isinstance(update, CharacterStateUpdate): + details_text.update( + f"Character: {update.character_name}\nHP Change: {update.hp_change}\nAdded Effects: {update.status_effects_added}\nRemoved Effects: {update.status_effects_removed}" + ) + + def remove_update(self, index: int) -> None: + # Remove from the pending list + del self.pending_updates[index] + + # Clear and refill the table + table = self.query_one("#update-table", DataTable) + table.clear() + + for i, update in enumerate(self.pending_updates): + if isinstance(update, LoreUpdate): + table.add_row( + "Lore", update.entity_name or "General", update.content, key=str(i) + ) + elif isinstance(update, CharacterStateUpdate): + change_text = f"HP: {update.hp_change or 0}" + if update.status_effects_added: + change_text += f", Added: {', '.join(update.status_effects_added)}" + if update.status_effects_removed: + change_text += ( + f", Removed: {', '.join(update.status_effects_removed)}" + ) + table.add_row("Char", update.character_name, change_text, key=str(i)) + + self.selected_index = -1 + self.query_one("#details-text", Static).update("No update selected") diff --git a/tests/__pycache__/test_persistence.cpython-314.pyc b/tests/__pycache__/test_persistence.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9a614ea68343076810331bda53fcf357148a1d00 GIT binary patch literal 6339 zcmeHLU2qfE6~3$8l~!8$4`f?5#*&eLq8iH~K%hWK0|DCw9A`^R+acZ&(%M!=mXy2e z0GiH($wOl@oq@J9t=l9MKf?5dm(K8*=BbZ37Fe?(ZDz_$CvOxyO`DhWoV!0-DNcZy zbfyoz8lAm+|M#5le)pU^+}so*kcM}(zgFBr$lvhANur&3cmpz*$P7`q31V1J&fwtP zH{l!S4L*it{Dd$r8sfO$@Q(+K04ozFr178;91j^G_AXAy<6$ElBkiP*DE>~O1YYH) zx}6DY&po`AH=18mz&YYz)?zVE<(Bb1a)oa;7kqHs+JO z896$irs<5K(H|LULwlu^1!=ZS6yDGZMv-2G>cX66hE5i#U77TmTjw-G%{U!Z9ebJ` zi>z;BIm{*7HF+2#N~3yCH?%@V%Q#cvjHpNWOAr47nM>rX zTc?mcc7Dp%H8yvWqt79`Alr%BKPx!(Q+_8`dxAyXF=O7DxF?#a>#=5X#{`S}z~b;v z^5q~{G(C#CFNatb4W~~g1&ZHj`n3z7Bi;1t^NU6UZ7= z5B-BD;h~2x6sa?G01B9)pN6lfH?+bjK806=eYW{Z+*#M;)2<>*zG*V$YO6vk+A zuH1q$o-e+k>G5=4OO*$X7J(zEo;GsDLi~Krn2)n2X(MjTYjGnDm4(!G!IUz<4s$>y zrVNylGcKxy^nzvvGQ|RHO~cMyFieh?+eWNGYV%B9mniNlirO_pXg3CpM74XsC$tI_0IbhskzyxY`vwSSqvqW(f%;i?^-zn%T%?62pS#;W1gE2l1> zy45{$?R+J??+cMMZ@;2mQQuL&d>DpF|J0c{B8VRe@{#u7|DpdKLSH&r1l$6&&EOfp zFU@LNsoe8I{M@2$pn?)v1tNCBRadjhP%A8xa1=t>NJhvz#g2&s?kI>@=P_7{7!Lt;O1B* z{Gu19Gzl|rj?MD_3@rnhy2K32wbQ0FkK3*x)rcGj-X&!qc)vr%-$3w4J`Jgk;I}qZ z!mARfLpcWr8RsE*@bn%*@X*2~cws7EG$zZj)49Vi}eqZszG%tvn&r|C`@U0(uW6Z*Dlw5J-`Ta7&ZBpU^oeqD;L zO40Ws%h`9kKZyMJ|bQTVzy*QWt*LjVWVPr*$l z*G03r7BDaeAUI#bSOKGp2u2W#x~k32Y8gXU)7h++H9HzgskQ*_N7ii1Il+nDUk5)E z2GC94d}%WG&#%7v3Y=T?>nJLR;y2J;Ad)@?F-D8 z%%?Wx*-cs=fbsNk5Qv_E>bAaWM_08kQB4d~d*d6jpLqn$Bp6$MqaqF7ZB48kUuzv+ z53cC@7{Ua*}EgWtQEd@3h4$oJlp&AZd z-dgTjIdVQ<{Fb!$NO8GzMXyp@R!=ob;i$Q{i<{)0UP@;#;BacTD?w6zv zp;w>R52xHQ-hz>J3~fJ$_1}t~xt?6_~W@hlh?ci?tc>IQd5 zdWKOneHrvLnQSs8mEE$asu@z%g<^IwuR%Vns_0Oo`vq?;s>+;sblVF>Ri${9L5EO` zpukN*pGR>V1z?|;?JB$N&1KZIVbI*UMKClB)uyVMJh<%HT*k;~`Mj#a{b7@*5V|2J z{eVHAVyMLg-m~kYApZ3>!1jSp6rXy~EQ|Xebj#u~?m>509OoYN$l{&{-Rtk^k{L+PVt^hN((Pmb#SjZWOKQn+=KKa}^>!Pz+r