From 28478afd6d11c601b14397ebd977abdba75d7ead Mon Sep 17 00:00:00 2001 From: haixuanTao Date: Thu, 29 Aug 2024 11:05:42 +0200 Subject: [PATCH 01/15] improve node-hub test by giving unique test file names and testing main function until dora Runtime Error in order to catch initialization issues. --- .../tests/test_distil_whisper.py | 10 + .../tests/test_placeholder.py | 2 - node-hub/dora-echo/tests/test_dora_echo.py | 9 + node-hub/dora-echo/tests/test_placeholder.py | 2 - node-hub/dora-internvl/README.md | 3 + .../dora-internvl/dora_internvl/__init__.py | 11 ++ node-hub/dora-internvl/dora_internvl/main.py | 178 ++++++++++++++++++ node-hub/dora-internvl/pyproject.toml | 32 ++++ .../dora-internvl/tests/test_dora_internvl.py | 9 + node-hub/dora-keyboard/tests/test_keyboard.py | 9 + .../dora-keyboard/tests/test_placeholder.py | 2 - .../dora-microphone/tests/test_microphone.py | 9 + .../dora-microphone/tests/test_placeholder.py | 2 - .../opencv-plot/tests/test_opencv_plot.py | 11 +- .../tests/test_opencv_video_capture.py | 11 +- .../pyarrow-assert/tests/test_placeholder.py | 2 - .../tests/test_pyarrow_assert.py | 9 + .../pyarrow-sender/tests/test_placeholder.py | 2 - .../tests/test_pyarrow_sender.py | 9 + .../terminal-input/tests/test_placeholder.py | 2 - .../tests/test_terminal_input.py | 9 + 21 files changed, 315 insertions(+), 18 deletions(-) create mode 100644 node-hub/dora-distil-whisper/tests/test_distil_whisper.py delete mode 100644 node-hub/dora-distil-whisper/tests/test_placeholder.py create mode 100644 node-hub/dora-echo/tests/test_dora_echo.py delete mode 100644 node-hub/dora-echo/tests/test_placeholder.py create mode 100644 node-hub/dora-internvl/README.md create mode 100644 node-hub/dora-internvl/dora_internvl/__init__.py create mode 100644 node-hub/dora-internvl/dora_internvl/main.py create mode 100644 node-hub/dora-internvl/pyproject.toml create mode 100644 node-hub/dora-internvl/tests/test_dora_internvl.py create mode 100644 node-hub/dora-keyboard/tests/test_keyboard.py delete mode 100644 node-hub/dora-keyboard/tests/test_placeholder.py create mode 100644 node-hub/dora-microphone/tests/test_microphone.py delete mode 100644 node-hub/dora-microphone/tests/test_placeholder.py delete mode 100644 node-hub/pyarrow-assert/tests/test_placeholder.py create mode 100644 node-hub/pyarrow-assert/tests/test_pyarrow_assert.py delete mode 100644 node-hub/pyarrow-sender/tests/test_placeholder.py create mode 100644 node-hub/pyarrow-sender/tests/test_pyarrow_sender.py delete mode 100644 node-hub/terminal-input/tests/test_placeholder.py create mode 100644 node-hub/terminal-input/tests/test_terminal_input.py diff --git a/node-hub/dora-distil-whisper/tests/test_distil_whisper.py b/node-hub/dora-distil-whisper/tests/test_distil_whisper.py new file mode 100644 index 000000000..0f4d8a59f --- /dev/null +++ b/node-hub/dora-distil-whisper/tests/test_distil_whisper.py @@ -0,0 +1,10 @@ +import pytest + + +def test_import_main(): + + from dora_distil_whisper.main import main + + # Check that everything is working, and catch dora Runtime Exception as we're not running in a dora dataflow. + with pytest.raises(RuntimeError): + main() diff --git a/node-hub/dora-distil-whisper/tests/test_placeholder.py b/node-hub/dora-distil-whisper/tests/test_placeholder.py deleted file mode 100644 index 201975fcc..000000000 --- a/node-hub/dora-distil-whisper/tests/test_placeholder.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_placeholder(): - pass diff --git a/node-hub/dora-echo/tests/test_dora_echo.py b/node-hub/dora-echo/tests/test_dora_echo.py new file mode 100644 index 000000000..fee400702 --- /dev/null +++ b/node-hub/dora-echo/tests/test_dora_echo.py @@ -0,0 +1,9 @@ +import pytest + + +def test_import_main(): + from dora_echo.main import main + + # Check that everything is working, and catch dora Runtime Exception as we're not running in a dora dataflow. + with pytest.raises(RuntimeError): + main() diff --git a/node-hub/dora-echo/tests/test_placeholder.py b/node-hub/dora-echo/tests/test_placeholder.py deleted file mode 100644 index 201975fcc..000000000 --- a/node-hub/dora-echo/tests/test_placeholder.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_placeholder(): - pass diff --git a/node-hub/dora-internvl/README.md b/node-hub/dora-internvl/README.md new file mode 100644 index 000000000..ea2e29a4e --- /dev/null +++ b/node-hub/dora-internvl/README.md @@ -0,0 +1,3 @@ +# Dora VLM + +Experimental node for using a VLM within dora. diff --git a/node-hub/dora-internvl/dora_internvl/__init__.py b/node-hub/dora-internvl/dora_internvl/__init__.py new file mode 100644 index 000000000..ac3cbef9f --- /dev/null +++ b/node-hub/dora-internvl/dora_internvl/__init__.py @@ -0,0 +1,11 @@ +import os + +# Define the path to the README file relative to the package directory +readme_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "README.md") + +# Read the content of the README file +try: + with open(readme_path, "r", encoding="utf-8") as f: + __doc__ = f.read() +except FileNotFoundError: + __doc__ = "README file not found." diff --git a/node-hub/dora-internvl/dora_internvl/main.py b/node-hub/dora-internvl/dora_internvl/main.py new file mode 100644 index 000000000..4bc2c4fd9 --- /dev/null +++ b/node-hub/dora-internvl/dora_internvl/main.py @@ -0,0 +1,178 @@ +import os +from dora import Node +import numpy as np +import pyarrow as pa +import torch +import torchvision.transforms as T +from PIL import Image +from torchvision.transforms.functional import InterpolationMode +from transformers import AutoModel, AutoTokenizer + +IMAGENET_MEAN = (0.485, 0.456, 0.406) +IMAGENET_STD = (0.229, 0.224, 0.225) + + +def build_transform(input_size): + MEAN, STD = IMAGENET_MEAN, IMAGENET_STD + transform = T.Compose( + [ + T.Lambda(lambda img: img.convert("RGB") if img.mode != "RGB" else img), + T.Resize((input_size, input_size), interpolation=InterpolationMode.BICUBIC), + T.ToTensor(), + T.Normalize(mean=MEAN, std=STD), + ] + ) + return transform + + +def find_closest_aspect_ratio(aspect_ratio, target_ratios, width, height, image_size): + best_ratio_diff = float("inf") + best_ratio = (1, 1) + area = width * height + for ratio in target_ratios: + target_aspect_ratio = ratio[0] / ratio[1] + ratio_diff = abs(aspect_ratio - target_aspect_ratio) + if ratio_diff < best_ratio_diff: + best_ratio_diff = ratio_diff + best_ratio = ratio + elif ratio_diff == best_ratio_diff: + if area > 0.5 * image_size * image_size * ratio[0] * ratio[1]: + best_ratio = ratio + return best_ratio + + +def dynamic_preprocess( + image, min_num=1, max_num=12, image_size=448, use_thumbnail=False +): + orig_width, orig_height = image.size + aspect_ratio = orig_width / orig_height + + # calculate the existing image aspect ratio + target_ratios = set( + (i, j) + for n in range(min_num, max_num + 1) + for i in range(1, n + 1) + for j in range(1, n + 1) + if i * j <= max_num and i * j >= min_num + ) + target_ratios = sorted(target_ratios, key=lambda x: x[0] * x[1]) + + # find the closest aspect ratio to the target + target_aspect_ratio = find_closest_aspect_ratio( + aspect_ratio, target_ratios, orig_width, orig_height, image_size + ) + + # calculate the target width and height + target_width = image_size * target_aspect_ratio[0] + target_height = image_size * target_aspect_ratio[1] + blocks = target_aspect_ratio[0] * target_aspect_ratio[1] + + # resize the image + resized_img = image.resize((target_width, target_height)) + processed_images = [] + for i in range(blocks): + box = ( + (i % (target_width // image_size)) * image_size, + (i // (target_width // image_size)) * image_size, + ((i % (target_width // image_size)) + 1) * image_size, + ((i // (target_width // image_size)) + 1) * image_size, + ) + # split the image + split_img = resized_img.crop(box) + processed_images.append(split_img) + assert len(processed_images) == blocks + if use_thumbnail and len(processed_images) != 1: + thumbnail_img = image.resize((image_size, image_size)) + processed_images.append(thumbnail_img) + return processed_images + + +def load_image(image_array: np.array, input_size=448, max_num=12): + image = Image.fromarray(image_array).convert("RGB") + transform = build_transform(input_size=input_size) + images = dynamic_preprocess( + image, image_size=input_size, use_thumbnail=True, max_num=max_num + ) + pixel_values = [transform(image) for image in images] + pixel_values = torch.stack(pixel_values) + return pixel_values + + +def main(): + # Handle dynamic nodes, ask for the name of the node in the dataflow, and the same values as the ENV variables. + model_path = os.getenv("MODEL", "OpenGVLab/InternVL2-1B") + + # If you want to load a model using multiple GPUs, please refer to the `Multiple GPUs` section. + model = ( + AutoModel.from_pretrained( + model_path, + torch_dtype=torch.bfloat16, + low_cpu_mem_usage=True, + use_flash_attn=True, + trust_remote_code=True, + ) + .eval() + .cuda() + ) + tokenizer = AutoTokenizer.from_pretrained( + model_path, trust_remote_code=True, use_fast=False + ) + + node = Node() + + pa.array([]) # initialize pyarrow array + + for event in node: + event_type = event["type"] + + if event_type == "INPUT": + event_id = event["id"] + + if event_id == "image": + storage = event["value"] + metadata = event["metadata"] + encoding = metadata["encoding"] + width = metadata["width"] + height = metadata["height"] + + if encoding == "bgr8": + channels = 3 + storage_type = np.uint8 + elif encoding == "rgb8": + channels = 3 + storage_type = np.uint8 + else: + raise RuntimeError(f"Unsupported image encoding: {encoding}") + + frame = ( + storage.to_numpy() + .astype(storage_type) + .reshape((height, width, channels)) + ) + if encoding == "bgr8": + frame = frame[:, :, ::-1] # OpenCV image (BGR to RGB) + elif encoding == "rgb8": + pass + else: + raise RuntimeError(f"Unsupported image encoding: {encoding}") + + # set the max number of tiles in `max_num` + pixel_values = load_image(frame, max_num=12).to(torch.bfloat16).cuda() + generation_config = dict(max_new_tokens=1024, do_sample=True) + question = "\nPlease describe the image shortly." + response = model.chat( + tokenizer, pixel_values, question, generation_config + ) + + node.send_output( + "response", + pa.array([response]), + metadata, + ) + + elif event_type == "ERROR": + raise RuntimeError(event["error"]) + + +if __name__ == "__main__": + main() diff --git a/node-hub/dora-internvl/pyproject.toml b/node-hub/dora-internvl/pyproject.toml new file mode 100644 index 000000000..8201d3fa8 --- /dev/null +++ b/node-hub/dora-internvl/pyproject.toml @@ -0,0 +1,32 @@ +[tool.poetry] +name = "dora-internvl" +version = "0.3.6" +authors = [ + "Haixuan Xavier Tao ", + "Enzo Le Van ", +] +description = "Dora Node for VLM" +readme = "README.md" + +packages = [{ include = "dora_internvl" }] + +[tool.poetry.dependencies] +python = "^3.7" +dora-rs = "^0.3.6" +numpy = "< 2.0.0" +torch = "^2.2.0" +torchvision = "^0.17" +transformers = "^4.11.3" +pillow = "^10.0.0" +bitsandbytes = "^0.41.0" +einops = "^0.6.1" +einops-exts = "^0.0.4" +timm = "^0.9.12" +sentencepiece = "^0.1.99" + +[tool.poetry.scripts] +dora-internvl = "dora_internvl.main:main" + +[build-system] +requires = ["poetry-core>=1.8.0"] +build-backend = "poetry.core.masonry.api" diff --git a/node-hub/dora-internvl/tests/test_dora_internvl.py b/node-hub/dora-internvl/tests/test_dora_internvl.py new file mode 100644 index 000000000..baabff781 --- /dev/null +++ b/node-hub/dora-internvl/tests/test_dora_internvl.py @@ -0,0 +1,9 @@ +import pytest + + +def test_import_main(): + from dora_internvl.main import main + + # Check that everything is working, and catch dora Runtime Exception as we're not running in a dora dataflow. + with pytest.raises(RuntimeError): + main() diff --git a/node-hub/dora-keyboard/tests/test_keyboard.py b/node-hub/dora-keyboard/tests/test_keyboard.py new file mode 100644 index 000000000..5ac295b75 --- /dev/null +++ b/node-hub/dora-keyboard/tests/test_keyboard.py @@ -0,0 +1,9 @@ +import pytest + + +def test_import_main(): + from dora_keyboard.main import main + + # Check that everything is working, and catch dora Runtime Exception as we're not running in a dora dataflow. + with pytest.raises(RuntimeError): + main() diff --git a/node-hub/dora-keyboard/tests/test_placeholder.py b/node-hub/dora-keyboard/tests/test_placeholder.py deleted file mode 100644 index 201975fcc..000000000 --- a/node-hub/dora-keyboard/tests/test_placeholder.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_placeholder(): - pass diff --git a/node-hub/dora-microphone/tests/test_microphone.py b/node-hub/dora-microphone/tests/test_microphone.py new file mode 100644 index 000000000..1e31830d6 --- /dev/null +++ b/node-hub/dora-microphone/tests/test_microphone.py @@ -0,0 +1,9 @@ +import pytest + + +def test_import_main(): + from dora_microphone.main import main + + # Check that everything is working, and catch dora Runtime Exception as we're not running in a dora dataflow. + with pytest.raises(RuntimeError): + main() diff --git a/node-hub/dora-microphone/tests/test_placeholder.py b/node-hub/dora-microphone/tests/test_placeholder.py deleted file mode 100644 index 201975fcc..000000000 --- a/node-hub/dora-microphone/tests/test_placeholder.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_placeholder(): - pass diff --git a/node-hub/opencv-plot/tests/test_opencv_plot.py b/node-hub/opencv-plot/tests/test_opencv_plot.py index 201975fcc..002899d2a 100644 --- a/node-hub/opencv-plot/tests/test_opencv_plot.py +++ b/node-hub/opencv-plot/tests/test_opencv_plot.py @@ -1,2 +1,9 @@ -def test_placeholder(): - pass +import pytest + + +def test_import_main(): + from opencv_plot.main import main + + # Check that everything is working, and catch dora Runtime Exception as we're not running in a dora dataflow. + with pytest.raises(RuntimeError): + main() diff --git a/node-hub/opencv-video-capture/tests/test_opencv_video_capture.py b/node-hub/opencv-video-capture/tests/test_opencv_video_capture.py index 201975fcc..bed0ab799 100644 --- a/node-hub/opencv-video-capture/tests/test_opencv_video_capture.py +++ b/node-hub/opencv-video-capture/tests/test_opencv_video_capture.py @@ -1,2 +1,9 @@ -def test_placeholder(): - pass +import pytest + + +def test_import_main(): + from opencv_video_capture.main import main + + # Check that everything is working, and catch dora Runtime Exception as we're not running in a dora dataflow. + with pytest.raises(RuntimeError): + main() diff --git a/node-hub/pyarrow-assert/tests/test_placeholder.py b/node-hub/pyarrow-assert/tests/test_placeholder.py deleted file mode 100644 index 201975fcc..000000000 --- a/node-hub/pyarrow-assert/tests/test_placeholder.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_placeholder(): - pass diff --git a/node-hub/pyarrow-assert/tests/test_pyarrow_assert.py b/node-hub/pyarrow-assert/tests/test_pyarrow_assert.py new file mode 100644 index 000000000..a54230d6f --- /dev/null +++ b/node-hub/pyarrow-assert/tests/test_pyarrow_assert.py @@ -0,0 +1,9 @@ +import pytest + + +def test_import_main(): + from pyarrow_assert.main import main + + # Check that everything is working, and catch dora Runtime Exception as we're not running in a dora dataflow. + with pytest.raises(RuntimeError): + main() diff --git a/node-hub/pyarrow-sender/tests/test_placeholder.py b/node-hub/pyarrow-sender/tests/test_placeholder.py deleted file mode 100644 index 201975fcc..000000000 --- a/node-hub/pyarrow-sender/tests/test_placeholder.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_placeholder(): - pass diff --git a/node-hub/pyarrow-sender/tests/test_pyarrow_sender.py b/node-hub/pyarrow-sender/tests/test_pyarrow_sender.py new file mode 100644 index 000000000..c97dc7513 --- /dev/null +++ b/node-hub/pyarrow-sender/tests/test_pyarrow_sender.py @@ -0,0 +1,9 @@ +import pytest + + +def test_import_main(): + from pyarrow_sender.main import main + + # Check that everything is working, and catch dora Runtime Exception as we're not running in a dora dataflow. + with pytest.raises(RuntimeError): + main() diff --git a/node-hub/terminal-input/tests/test_placeholder.py b/node-hub/terminal-input/tests/test_placeholder.py deleted file mode 100644 index 201975fcc..000000000 --- a/node-hub/terminal-input/tests/test_placeholder.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_placeholder(): - pass diff --git a/node-hub/terminal-input/tests/test_terminal_input.py b/node-hub/terminal-input/tests/test_terminal_input.py new file mode 100644 index 000000000..2b5024a3f --- /dev/null +++ b/node-hub/terminal-input/tests/test_terminal_input.py @@ -0,0 +1,9 @@ +import pytest + + +def test_import_main(): + from terminal_input.main import main + + # Check that everything is working, and catch dora Runtime Exception as we're not running in a dora dataflow. + with pytest.raises(RuntimeError): + main() From 205225c7cd9d2f22cfb740c17adee29f354f8844 Mon Sep 17 00:00:00 2001 From: haixuanTao Date: Thu, 29 Aug 2024 14:56:01 +0200 Subject: [PATCH 02/15] Small improvements --- .../dora_distil_whisper/main.py | 2 - node-hub/dora-internvl/dora_internvl/main.py | 5 +- .../terminal-input/terminal_input/main.py | 75 +++++++++++-------- 3 files changed, 48 insertions(+), 34 deletions(-) diff --git a/node-hub/dora-distil-whisper/dora_distil_whisper/main.py b/node-hub/dora-distil-whisper/dora_distil_whisper/main.py index d938ddc80..135f85275 100644 --- a/node-hub/dora-distil-whisper/dora_distil_whisper/main.py +++ b/node-hub/dora-distil-whisper/dora_distil_whisper/main.py @@ -2,9 +2,7 @@ from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline from dora import Node import pyarrow as pa -import os -os.environ["TRANSFORMERS_OFFLINE"] = "1" device = "cuda:0" if torch.cuda.is_available() else "cpu" torch_dtype = torch.float16 if torch.cuda.is_available() else torch.float32 diff --git a/node-hub/dora-internvl/dora_internvl/main.py b/node-hub/dora-internvl/dora_internvl/main.py index 4bc2c4fd9..a0083e1de 100644 --- a/node-hub/dora-internvl/dora_internvl/main.py +++ b/node-hub/dora-internvl/dora_internvl/main.py @@ -120,6 +120,7 @@ def main(): node = Node() + question = "\nPlease describe the image shortly." pa.array([]) # initialize pyarrow array for event in node: @@ -159,7 +160,6 @@ def main(): # set the max number of tiles in `max_num` pixel_values = load_image(frame, max_num=12).to(torch.bfloat16).cuda() generation_config = dict(max_new_tokens=1024, do_sample=True) - question = "\nPlease describe the image shortly." response = model.chat( tokenizer, pixel_values, question, generation_config ) @@ -170,6 +170,9 @@ def main(): metadata, ) + elif event_id == "text": + question = "\n" + event["value"][0].as_py() + elif event_type == "ERROR": raise RuntimeError(event["error"]) diff --git a/node-hub/terminal-input/terminal_input/main.py b/node-hub/terminal-input/terminal_input/main.py index 39fb3c622..37c289e41 100644 --- a/node-hub/terminal-input/terminal_input/main.py +++ b/node-hub/terminal-input/terminal_input/main.py @@ -1,7 +1,7 @@ import argparse import os import ast - +import time import pyarrow as pa from dora import Node @@ -32,16 +32,50 @@ def main(): args = parser.parse_args() data = os.getenv("DATA", args.data) + last_err = "" + while True: + try: + node = Node( + args.name + ) # provide the name to connect to the dataflow if dynamic node + except RuntimeError as err: + if err != last_err: + print(err) + last_err = err + print("Waiting for dataflow to be spawned") + time.sleep(1) - node = Node( - args.name - ) # provide the name to connect to the dataflow if dynamic node - - if data is None and os.getenv("DORA_NODE_CONFIG") is None: - while True: - data = input( - "Provide the data you want to send: ", - ) + if data is None and os.getenv("DORA_NODE_CONFIG") is None: + while True: + data = input( + "Provide the data you want to send: ", + ) + try: + data = ast.literal_eval(data) + except ValueError: + print("Passing input as string") + except SyntaxError: + print("Passing input as string") + if isinstance(data, list): + data = pa.array(data) # initialize pyarrow array + elif isinstance(data, str): + data = pa.array([data]) + elif isinstance(data, int): + data = pa.array([data]) + elif isinstance(data, float): + data = pa.array([data]) + elif isinstance(data, dict): + data = pa.array([data]) + else: + data = pa.array(data) # initialize pyarrow array + node.send_output("data", data) + while True: + event = node.next(timeout=0.2) + if event is not None and event["type"] == "INPUT": + print(f"Received: {event['value'].to_pylist()}") + else: + break + else: try: data = ast.literal_eval(data) except ValueError: @@ -59,27 +93,6 @@ def main(): else: data = pa.array(data) # initialize pyarrow array node.send_output("data", data) - event = node.next(timeout=0.2) - if event is not None: - print(f"Received: {event['value'].to_pylist()}") - else: - try: - data = ast.literal_eval(data) - except ValueError: - print("Passing input as string") - if isinstance(data, list): - data = pa.array(data) # initialize pyarrow array - elif isinstance(data, str): - data = pa.array([data]) - elif isinstance(data, int): - data = pa.array([data]) - elif isinstance(data, float): - data = pa.array([data]) - elif isinstance(data, dict): - data = pa.array([data]) - else: - data = pa.array(data) # initialize pyarrow array - node.send_output("data", data) if __name__ == "__main__": From 20fe2264e31f0587689affcf19d4bfba9d666a5a Mon Sep 17 00:00:00 2001 From: haixuanTao Date: Fri, 30 Aug 2024 06:28:28 +0200 Subject: [PATCH 03/15] Add Speech to Text Node using Parler compiled version --- node-hub/dora-distil-whisper/pyproject.toml | 2 +- node-hub/dora-internvl/dora_internvl/main.py | 2 +- node-hub/dora-parler/README.md | 1 + node-hub/dora-parler/dora_parler/__init__.py | 11 ++ node-hub/dora-parler/dora_parler/main.py | 146 ++++++++++++++++++ node-hub/dora-parler/pyproject.toml | 30 ++++ node-hub/dora-parler/tests/test_parler_tts.py | 9 ++ 7 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 node-hub/dora-parler/README.md create mode 100644 node-hub/dora-parler/dora_parler/__init__.py create mode 100644 node-hub/dora-parler/dora_parler/main.py create mode 100644 node-hub/dora-parler/pyproject.toml create mode 100644 node-hub/dora-parler/tests/test_parler_tts.py diff --git a/node-hub/dora-distil-whisper/pyproject.toml b/node-hub/dora-distil-whisper/pyproject.toml index 7e520a3b7..71bf3ecfa 100644 --- a/node-hub/dora-distil-whisper/pyproject.toml +++ b/node-hub/dora-distil-whisper/pyproject.toml @@ -18,7 +18,7 @@ numpy = "< 2.0.0" pyarrow = ">= 5.0.0" transformers = ">= 4.0.0" accelerate = "^0.29.2" -torch = "^2.1.1" +torch = "^2.2.0" python = "^3.7" [tool.poetry.scripts] diff --git a/node-hub/dora-internvl/dora_internvl/main.py b/node-hub/dora-internvl/dora_internvl/main.py index a0083e1de..7944a1f17 100644 --- a/node-hub/dora-internvl/dora_internvl/main.py +++ b/node-hub/dora-internvl/dora_internvl/main.py @@ -165,7 +165,7 @@ def main(): ) node.send_output( - "response", + "text", pa.array([response]), metadata, ) diff --git a/node-hub/dora-parler/README.md b/node-hub/dora-parler/README.md new file mode 100644 index 000000000..8f2873338 --- /dev/null +++ b/node-hub/dora-parler/README.md @@ -0,0 +1 @@ +# Dora Speech-to-Text Node using Huggingface Parler diff --git a/node-hub/dora-parler/dora_parler/__init__.py b/node-hub/dora-parler/dora_parler/__init__.py new file mode 100644 index 000000000..ac3cbef9f --- /dev/null +++ b/node-hub/dora-parler/dora_parler/__init__.py @@ -0,0 +1,11 @@ +import os + +# Define the path to the README file relative to the package directory +readme_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "README.md") + +# Read the content of the README file +try: + with open(readme_path, "r", encoding="utf-8") as f: + __doc__ = f.read() +except FileNotFoundError: + __doc__ = "README file not found." diff --git a/node-hub/dora-parler/dora_parler/main.py b/node-hub/dora-parler/dora_parler/main.py new file mode 100644 index 000000000..d3cbbfb8f --- /dev/null +++ b/node-hub/dora-parler/dora_parler/main.py @@ -0,0 +1,146 @@ +from threading import Thread +from dora import Node + +import numpy as np +import torch +import time +import pyaudio + +from parler_tts import ParlerTTSForConditionalGeneration, ParlerTTSStreamer +from transformers import ( + AutoTokenizer, + AutoFeatureExtractor, + set_seed, + StoppingCriteria, + StoppingCriteriaList, +) + +device = "cuda:0" # if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu" +torch_dtype = torch.float16 if device != "cpu" else torch.float32 + +repo_id = "ylacombe/parler-tts-mini-jenny-30H" + +model = ParlerTTSForConditionalGeneration.from_pretrained( + repo_id, torch_dtype=torch_dtype, low_cpu_mem_usage=True +).to(device) +model.generation_config.cache_implementation = "static" +model.forward = torch.compile(model.forward, mode="reduce-overhead") + +tokenizer = AutoTokenizer.from_pretrained(repo_id) +feature_extractor = AutoFeatureExtractor.from_pretrained(repo_id) + +SAMPLE_RATE = feature_extractor.sampling_rate +SEED = 42 + +default_text = "Hello, my name is Reachy the best robot in the world !" +default_description = ( + "Jenny delivers her words quite expressively, in a very confined sounding environment with clear audio quality.", +) +init_sleep = True + + +p = pyaudio.PyAudio() + + +sampling_rate = model.audio_encoder.config.sampling_rate +frame_rate = model.audio_encoder.config.frame_rate + +stream = p.open(format=pyaudio.paInt16, channels=1, rate=sampling_rate, output=True) + + +def play_audio(audio_array): + + if np.issubdtype(audio_array.dtype, np.floating): + max_val = np.max(np.abs(audio_array)) + audio_array = (audio_array / max_val) * 32767 + audio_array = audio_array.astype(np.int16) + + stream.write(audio_array.tobytes()) + + +class InterruptStoppingCriteria(StoppingCriteria): + def __init__(self): + self.stop_signal = False + + def __call__( + self, input_ids: torch.LongTensor, scores: torch.FloatTensor, **kwargs + ) -> bool: + return self.stop_signal + + def stop(self): + self.stop_signal = True + + +def generate_base( + node, + text=default_text, + description=default_description, + play_steps_in_s=0.5, +): + prev_time = time.time() + global init_sleep + play_steps = int(frame_rate * play_steps_in_s) + inputs = tokenizer(description, return_tensors="pt").to(device) + prompt = tokenizer(text, return_tensors="pt").to(device) + streamer = ParlerTTSStreamer(model, device=device, play_steps=play_steps) + + stopping_criteria = InterruptStoppingCriteria() + + generation_kwargs = dict( + input_ids=inputs.input_ids, + prompt_input_ids=prompt.input_ids, + streamer=streamer, + do_sample=True, + temperature=1.0, + min_new_tokens=10, + stopping_criteria=StoppingCriteriaList([stopping_criteria]), + ) + set_seed(SEED) + thread = Thread(target=model.generate, kwargs=generation_kwargs) + thread.start() + + for new_audio in streamer: + + current_time = time.time() + + print(f"Time between iterations: {round(current_time - prev_time, 2)} seconds") + prev_time = current_time + play_audio(new_audio) + + if node is None: + continue + + event = node.next(timeout=0.01) + + if event["type"] == "ERROR": + pass + elif event["type"] == "INPUT": + if event["id"] == "stop": + stopping_criteria.stop() + break + elif event["id"] == "text": + text = event["value"][0].as_py() + stopping_criteria.stop() + generate_base(node, text, default_description, 0.5) + + +def main(): + generate_base(None, "Ready !", default_description, 0.5) + generate_base(None, "Ready !", default_description, 0.5) + generate_base(None, "Ready !", default_description, 0.5) + node = Node() + while True: + event = node.next() + if event is None: + break + if event["type"] == "INPUT" and event["id"] == "text": + text = event["value"][0].as_py() + generate_base(node, text, default_description, 0.5) + + stream.stop_stream() + stream.close() + p.terminate() + + +if __name__ == "__main__": + main() diff --git a/node-hub/dora-parler/pyproject.toml b/node-hub/dora-parler/pyproject.toml new file mode 100644 index 000000000..3f1591cd6 --- /dev/null +++ b/node-hub/dora-parler/pyproject.toml @@ -0,0 +1,30 @@ +[tool.poetry] +name = "dora-parler" +version = "0.3.6" +authors = [ + "Haixuan Xavier Tao ", + "Enzo Le Van ", +] +description = "Dora Node for Text to speech with dora Parler-TTS" +readme = "README.md" + +packages = [{ include = "dora_parler" }] + +[tool.poetry.dependencies] +dora-rs = "^0.3.6" +numpy = "< 2.0.0" +parler_tts = { git = "https://github.com/huggingface/parler-tts.git" } +transformers = ">=4.43.0,<=4.43.3" +torch = "^2.2.0" +torchaudio = "^2.2.2" +sentencepiece = "^0.1.99" +python = "^3.7" +pyaudio = "^0.2.14" + + +[tool.poetry.scripts] +dora-parler = "dora_parler.main:main" + +[build-system] +requires = ["poetry-core>=1.8.0"] +build-backend = "poetry.core.masonry.api" diff --git a/node-hub/dora-parler/tests/test_parler_tts.py b/node-hub/dora-parler/tests/test_parler_tts.py new file mode 100644 index 000000000..fdb34db2f --- /dev/null +++ b/node-hub/dora-parler/tests/test_parler_tts.py @@ -0,0 +1,9 @@ +import pytest + + +def test_import_main(): + from dora_parler.main import main + + # Check that everything is working, and catch dora Runtime Exception as we're not running in a dora dataflow. + with pytest.raises(RuntimeError): + main() From 215331e5748c19c3c145e08c418876c41b78960f Mon Sep 17 00:00:00 2001 From: haixuanTao Date: Fri, 30 Aug 2024 07:27:41 +0200 Subject: [PATCH 04/15] make vlm generate on text and not on image --- node-hub/dora-internvl/dora_internvl/main.py | 28 +++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/node-hub/dora-internvl/dora_internvl/main.py b/node-hub/dora-internvl/dora_internvl/main.py index 7944a1f17..5a0126367 100644 --- a/node-hub/dora-internvl/dora_internvl/main.py +++ b/node-hub/dora-internvl/dora_internvl/main.py @@ -121,6 +121,7 @@ def main(): node = Node() question = "\nPlease describe the image shortly." + frame = None pa.array([]) # initialize pyarrow array for event in node: @@ -157,21 +158,22 @@ def main(): else: raise RuntimeError(f"Unsupported image encoding: {encoding}") - # set the max number of tiles in `max_num` - pixel_values = load_image(frame, max_num=12).to(torch.bfloat16).cuda() - generation_config = dict(max_new_tokens=1024, do_sample=True) - response = model.chat( - tokenizer, pixel_values, question, generation_config - ) - - node.send_output( - "text", - pa.array([response]), - metadata, - ) - elif event_id == "text": question = "\n" + event["value"][0].as_py() + if frame is not None: + # set the max number of tiles in `max_num` + pixel_values = ( + load_image(frame, max_num=12).to(torch.bfloat16).cuda() + ) + generation_config = dict(max_new_tokens=1024, do_sample=True) + response = model.chat( + tokenizer, pixel_values, question, generation_config + ) + node.send_output( + "text", + pa.array([response]), + metadata, + ) elif event_type == "ERROR": raise RuntimeError(event["error"]) From 9be5b765b94a8212011f3cb6e5b4e74f2f487269 Mon Sep 17 00:00:00 2001 From: haixuanTao Date: Fri, 30 Aug 2024 07:27:58 +0200 Subject: [PATCH 05/15] small parler refactoring --- node-hub/dora-parler/dora_parler/main.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/node-hub/dora-parler/dora_parler/main.py b/node-hub/dora-parler/dora_parler/main.py index d3cbbfb8f..b57a1f696 100644 --- a/node-hub/dora-parler/dora_parler/main.py +++ b/node-hub/dora-parler/dora_parler/main.py @@ -24,7 +24,7 @@ repo_id, torch_dtype=torch_dtype, low_cpu_mem_usage=True ).to(device) model.generation_config.cache_implementation = "static" -model.forward = torch.compile(model.forward, mode="reduce-overhead") +model.forward = torch.compile(model.forward, mode="default") tokenizer = AutoTokenizer.from_pretrained(repo_id) feature_extractor = AutoFeatureExtractor.from_pretrained(repo_id) @@ -36,7 +36,6 @@ default_description = ( "Jenny delivers her words quite expressively, in a very confined sounding environment with clear audio quality.", ) -init_sleep = True p = pyaudio.PyAudio() @@ -78,7 +77,6 @@ def generate_base( play_steps_in_s=0.5, ): prev_time = time.time() - global init_sleep play_steps = int(frame_rate * play_steps_in_s) inputs = tokenizer(description, return_tensors="pt").to(device) prompt = tokenizer(text, return_tensors="pt").to(device) @@ -119,14 +117,13 @@ def generate_base( stopping_criteria.stop() break elif event["id"] == "text": - text = event["value"][0].as_py() stopping_criteria.stop() + + text = event["value"][0].as_py() generate_base(node, text, default_description, 0.5) def main(): - generate_base(None, "Ready !", default_description, 0.5) - generate_base(None, "Ready !", default_description, 0.5) generate_base(None, "Ready !", default_description, 0.5) node = Node() while True: From 7b94257e0e11933baa351bdf30bbad5389f5bc75 Mon Sep 17 00:00:00 2001 From: haixuanTao Date: Sat, 31 Aug 2024 14:21:56 +0200 Subject: [PATCH 06/15] Add timer limit to microphone recording --- node-hub/dora-microphone/dora_microphone/main.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/node-hub/dora-microphone/dora_microphone/main.py b/node-hub/dora-microphone/dora_microphone/main.py index 5cd2a1745..ada80416e 100644 --- a/node-hub/dora-microphone/dora_microphone/main.py +++ b/node-hub/dora-microphone/dora_microphone/main.py @@ -30,22 +30,31 @@ def main(): buffer = [] state = RecordingState.PENDING silence_start_time = tm.time() + start_recording_time = tm.time() + max_duration = 20 node = Node() # pylint: disable=unused-argument def callback(indata, frames, time, status): - nonlocal buffer, state, silence_start_time, node + nonlocal buffer, state, silence_start_time, node, max_duration, start_recording_time is_speaking = detect_speech(indata[:, 0], threshold) if is_speaking: if state == RecordingState.PENDING: buffer = [] state = RecordingState.RUNNING + start_recording_time = tm.time() buffer.extend(indata[:, 0]) elif not is_speaking and state == RecordingState.RUNNING: silence_start_time = tm.time() # Reset silence timer buffer.extend(indata[:, 0]) state = RecordingState.SILENCE + elif ( + state == RecordingState.RUNNING or state == RecordingState.SILENCE + ) and tm.time() - start_recording_time > max_duration: + audio_data = np.array(buffer).ravel().astype(np.float32) / 32768.0 + node.send_output("audio", pa.array(audio_data)) + state = RecordingState.PENDING elif not is_speaking and state == RecordingState.SILENCE: if tm.time() - silence_start_time > silence_duration: audio_data = np.array(buffer).ravel().astype(np.float32) / 32768.0 From 9ccff3131e8e0d6dfd9401b874584b890708ec5a Mon Sep 17 00:00:00 2001 From: haixuanTao Date: Sat, 31 Aug 2024 14:23:36 +0200 Subject: [PATCH 07/15] Remove minor typo in dora-distil-whisper --- node-hub/dora-distil-whisper/dora_distil_whisper/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/node-hub/dora-distil-whisper/dora_distil_whisper/main.py b/node-hub/dora-distil-whisper/dora_distil_whisper/main.py index 135f85275..68cc3a201 100644 --- a/node-hub/dora-distil-whisper/dora_distil_whisper/main.py +++ b/node-hub/dora-distil-whisper/dora_distil_whisper/main.py @@ -27,7 +27,6 @@ max_new_tokens=128, torch_dtype=torch_dtype, device=device, - generate_kwargs={"language": "chinese"}, ) From 7b7f185f4f3f65ab7bf15dc1f74ae8744a7be705 Mon Sep 17 00:00:00 2001 From: haixuanTao Date: Sat, 31 Aug 2024 14:25:09 +0200 Subject: [PATCH 08/15] Improve yolo testing --- node-hub/dora-yolo/tests/test_ultralytics_yolo.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/node-hub/dora-yolo/tests/test_ultralytics_yolo.py b/node-hub/dora-yolo/tests/test_ultralytics_yolo.py index 201975fcc..fa917b2e5 100644 --- a/node-hub/dora-yolo/tests/test_ultralytics_yolo.py +++ b/node-hub/dora-yolo/tests/test_ultralytics_yolo.py @@ -1,2 +1,9 @@ -def test_placeholder(): - pass +import pytest + + +def test_import_main(): + from dora_yolo.main import main + + # Check that everything is working, and catch dora Runtime Exception as we're not running in a dora dataflow. + with pytest.raises(RuntimeError): + main() From 97398a3ebac1c8e33f4f28b310af8e43aa6d7924 Mon Sep 17 00:00:00 2001 From: haixuanTao Date: Sun, 1 Sep 2024 14:37:12 +0200 Subject: [PATCH 09/15] ignore node with model on hf --- .github/workflows/node_hub_test.sh | 39 ++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/.github/workflows/node_hub_test.sh b/.github/workflows/node_hub_test.sh index 524ca1ca3..2cb31371d 100755 --- a/.github/workflows/node_hub_test.sh +++ b/.github/workflows/node_hub_test.sh @@ -1,20 +1,33 @@ #!/bin/bash set -euo +# List of ignored modules +# TODO: Fix HF github action issue. +ignored_folders=("dora-distil-whisper" "dora-internvl" "dora-parler" "dora-qwenvl" ) + for dir in node-hub/*/ ; do -if [ -d "$dir" ]; then - if [ -f "$dir/pyproject.toml" ]; then - echo "Running linting and tests for Python project in $dir..." - (cd "$dir" && pip install .) - (cd "$dir" && poetry run black --check .) - (cd "$dir" && poetry run pylint --disable=C,R --ignored-modules=cv2 **/*.py) - (cd "$dir" && poetry run pytest) + # Get the base name of the directory (without the path) + base_dir=$(basename "$dir") + + # Check if the directory name is in the ignored list + if [[ " ${ignored_folders[@]} " =~ " ${base_dir} " ]]; then + echo "Skipping $base_dir as there is a hf model fetching issue..." + continue fi - - if [ -f "$dir/Cargo.toml" ]; then - echo "Running build and tests for Rust project in $dir..." - (cd "$dir" && cargo build) - (cd "$dir" && cargo test) + + if [ -d "$dir" ]; then + if [ -f "$dir/pyproject.toml" ]; then + echo "Running linting and tests for Python project in $dir..." + (cd "$dir" && pip install .) + (cd "$dir" && poetry run black --check .) + (cd "$dir" && poetry run pylint --disable=C,R --ignored-modules=cv2 **/*.py) + (cd "$dir" && poetry run pytest) + fi + + if [ -f "$dir/Cargo.toml" ]; then + echo "Running build and tests for Rust project in $dir..." + (cd "$dir" && cargo build) + (cd "$dir" && cargo test) + fi fi -fi done From 8f290122847b4085ceb857f4a3f823eadac7b4e6 Mon Sep 17 00:00:00 2001 From: haixuanTao Date: Wed, 4 Sep 2024 11:57:35 +0200 Subject: [PATCH 10/15] Removing keyboard and microphone as it does not work within the CI --- .github/workflows/node_hub_test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node_hub_test.sh b/.github/workflows/node_hub_test.sh index 2cb31371d..b7dc48c55 100755 --- a/.github/workflows/node_hub_test.sh +++ b/.github/workflows/node_hub_test.sh @@ -3,7 +3,7 @@ set -euo # List of ignored modules # TODO: Fix HF github action issue. -ignored_folders=("dora-distil-whisper" "dora-internvl" "dora-parler" "dora-qwenvl" ) +ignored_folders=("dora-distil-whisper" "dora-internvl" "dora-parler" "dora-qwenvl", "dora-keyboard", "dora-microphone" ) for dir in node-hub/*/ ; do # Get the base name of the directory (without the path) From db0b9df0a71029e72a0706fe28e65fd45794d7d4 Mon Sep 17 00:00:00 2001 From: haixuanTao Date: Mon, 9 Sep 2024 14:23:56 +0200 Subject: [PATCH 11/15] Removing comma in shell script --- .github/workflows/node_hub_test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node_hub_test.sh b/.github/workflows/node_hub_test.sh index b7dc48c55..8acebd84d 100755 --- a/.github/workflows/node_hub_test.sh +++ b/.github/workflows/node_hub_test.sh @@ -3,7 +3,7 @@ set -euo # List of ignored modules # TODO: Fix HF github action issue. -ignored_folders=("dora-distil-whisper" "dora-internvl" "dora-parler" "dora-qwenvl", "dora-keyboard", "dora-microphone" ) +ignored_folders=("dora-distil-whisper" "dora-internvl" "dora-parler" "dora-qwenvl" "dora-keyboard" "dora-microphone" ) for dir in node-hub/*/ ; do # Get the base name of the directory (without the path) From 65271cbcbe29b5e01c3e102d991b07aeccb6de0a Mon Sep 17 00:00:00 2001 From: haixuanTao Date: Mon, 9 Sep 2024 15:26:21 +0200 Subject: [PATCH 12/15] Ignoring terminal input --- .github/workflows/node_hub_test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node_hub_test.sh b/.github/workflows/node_hub_test.sh index 8acebd84d..4c14efa28 100755 --- a/.github/workflows/node_hub_test.sh +++ b/.github/workflows/node_hub_test.sh @@ -3,7 +3,7 @@ set -euo # List of ignored modules # TODO: Fix HF github action issue. -ignored_folders=("dora-distil-whisper" "dora-internvl" "dora-parler" "dora-qwenvl" "dora-keyboard" "dora-microphone" ) +ignored_folders=("dora-distil-whisper" "dora-internvl" "dora-parler" "dora-qwenvl" "dora-keyboard" "dora-microphone" "terminal-input") for dir in node-hub/*/ ; do # Get the base name of the directory (without the path) From bf2ddeea91985e4ea0ebb3db32df09fc88dd86b8 Mon Sep 17 00:00:00 2001 From: haixuanTao Date: Mon, 9 Sep 2024 16:26:29 +0200 Subject: [PATCH 13/15] Fix transformers version --- .github/workflows/node_hub_test.sh | 2 +- node-hub/dora-distil-whisper/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/node_hub_test.sh b/.github/workflows/node_hub_test.sh index 4c14efa28..edc6e832a 100755 --- a/.github/workflows/node_hub_test.sh +++ b/.github/workflows/node_hub_test.sh @@ -3,7 +3,7 @@ set -euo # List of ignored modules # TODO: Fix HF github action issue. -ignored_folders=("dora-distil-whisper" "dora-internvl" "dora-parler" "dora-qwenvl" "dora-keyboard" "dora-microphone" "terminal-input") +ignored_folders=("dora-internvl" "dora-parler" "dora-qwenvl" "dora-keyboard" "dora-microphone" "terminal-input") for dir in node-hub/*/ ; do # Get the base name of the directory (without the path) diff --git a/node-hub/dora-distil-whisper/pyproject.toml b/node-hub/dora-distil-whisper/pyproject.toml index 71bf3ecfa..4506a2a34 100644 --- a/node-hub/dora-distil-whisper/pyproject.toml +++ b/node-hub/dora-distil-whisper/pyproject.toml @@ -16,7 +16,7 @@ packages = [{ include = "dora_distil_whisper" }] dora-rs = "^0.3.6" numpy = "< 2.0.0" pyarrow = ">= 5.0.0" -transformers = ">= 4.0.0" +transformers = "^4.0.0" accelerate = "^0.29.2" torch = "^2.2.0" python = "^3.7" From 07a991bfa0515d339b81c418021425df6a8ae703 Mon Sep 17 00:00:00 2001 From: haixuanTao Date: Mon, 9 Sep 2024 16:39:56 +0200 Subject: [PATCH 14/15] Remove local only that is failing dora-distil-whishper --- node-hub/dora-distil-whisper/dora_distil_whisper/main.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/node-hub/dora-distil-whisper/dora_distil_whisper/main.py b/node-hub/dora-distil-whisper/dora_distil_whisper/main.py index 68cc3a201..8e4acc72c 100644 --- a/node-hub/dora-distil-whisper/dora_distil_whisper/main.py +++ b/node-hub/dora-distil-whisper/dora_distil_whisper/main.py @@ -10,11 +10,7 @@ model_id = "distil-whisper/distil-large-v3" model = AutoModelForSpeechSeq2Seq.from_pretrained( - model_id, - torch_dtype=torch_dtype, - low_cpu_mem_usage=True, - use_safetensors=True, - local_files_only=True, + model_id, torch_dtype=torch_dtype, low_cpu_mem_usage=True, use_safetensors=True ) model.to(device) From a609754e04e3ad8ab6635007e2486a0c134e10f8 Mon Sep 17 00:00:00 2001 From: haixuanTao Date: Mon, 9 Sep 2024 16:58:01 +0200 Subject: [PATCH 15/15] Add test for both parler and qwenvl2 --- .github/workflows/node_hub_test.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/node_hub_test.sh b/.github/workflows/node_hub_test.sh index edc6e832a..dc2eacfb9 100755 --- a/.github/workflows/node_hub_test.sh +++ b/.github/workflows/node_hub_test.sh @@ -2,8 +2,7 @@ set -euo # List of ignored modules -# TODO: Fix HF github action issue. -ignored_folders=("dora-internvl" "dora-parler" "dora-qwenvl" "dora-keyboard" "dora-microphone" "terminal-input") +ignored_folders=("dora-internvl" "dora-parler" "dora-keyboard" "dora-microphone" "terminal-input") for dir in node-hub/*/ ; do # Get the base name of the directory (without the path)