From c5e110e58b6bb0f5c777084a1edcadb7b86eb5b7 Mon Sep 17 00:00:00 2001 From: "Salvador E. Tropea" Date: Wed, 16 Jul 2025 08:16:51 -0300 Subject: [PATCH] [Added][Audio Musical Note] With test and example --- README.md | 14 ++++++ example_workflows/generate_and_blend.jpg | Bin 0 -> 9766 bytes example_workflows/generate_and_blend.json | 1 + source/nodes/nodes_audio.py | 42 +++++++++++++++- source/nodes/utils/misc.py | 57 ++++++++++++++++++++++ source/tests/test_audio_note_node.py | 37 ++++++++++++++ source/tests/test_misc_utils.py | 51 ++++++++++++++++++- 7 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 example_workflows/generate_and_blend.jpg create mode 100644 example_workflows/generate_and_blend.json create mode 100644 source/tests/test_audio_note_node.py diff --git a/README.md b/README.md index 5dd6990..0099c69 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ workflows, especially when dealing with multiple audio inputs or outputs. - [8. Audio Cut](#8-audio-cut) - [9. Audio Blend](#9-audio-blend) - [10. Audio Test Signal Generator](#10-audio-test-signal-generator) + - [11. Audio Musical Note](#11-audio-musical-note) - [🚀 Installation](#-installation) - [📦 Dependencies](#-dependencies) - [🖼️ Examples](#️-examples) @@ -183,6 +184,17 @@ workflows, especially when dealing with multiple audio inputs or outputs. - **Output:** - `audio_out` (AUDIO): The generated test signal. +### 11. Audio Musical Note + - **Display Name:** `Audio Musical Note` + - **Internal Name:** `SET_AudioMusicalNote` + - **Category:** `audio/generation` + - **Description:** Converts a musical note (e.g., C#, Gb) and an octave into its corresponding frequency in Hz. This is perfect for accurately setting the frequency of the `Audio Test Signal Generator` node. + - **Inputs:** + - `note` (STRING): The musical note name. It's case-insensitive and flexible, accepting formats like `"C#"`, `"Db"`, `"g sharp"`, or `"a flat"`. + - `octave` (INT): The octave number for the note. Octave 4 is the standard middle range (containing A4=440Hz). + - **Output:** + - `frequency` (FLOAT): The calculated frequency of the note in Hz. + ## 🚀 Installation You can install the nodes from the ComfyUI nodes manager, the name is *Audio Batch*, or just do it manually: @@ -212,6 +224,8 @@ Once installed the examples are available in the ComfyUI workflow templates, in how to extract a single element from the batch. - [resample_force_stereo.json](example_workflows/resample_force_stereo.json): Shows how to change the number of channels and the sample rate. +- [generate_and_blend.json](example_workflows/generate_and_blend.json): Shows how to generate four musical notes and blend + them together to create a chord. ## 📝 Usage Notes diff --git a/example_workflows/generate_and_blend.jpg b/example_workflows/generate_and_blend.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6db97f3c8da4467606bbc9fb7dbd4363b5cd6819 GIT binary patch literal 9766 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 z float: "Expected 'SECONDS', 'MM:SS.ss', or 'HH:MM:SS.ss'.") from e except RuntimeError as e: raise ValueError(str(e)) + + +# Reference frequency for A4, the standard tuning pitch +A4_FREQ = 440.0 +NOTES = { + 'c': -9, 'c#': -8, 'db': -8, + 'd': -7, 'd#': -6, 'eb': -6, + 'e': -5, + 'f': -4, 'f#': -3, 'gb': -3, + 'g': -2, 'g#': -1, 'ab': -1, + 'a': 0, 'a#': 1, 'bb': 1, + 'b': 2, +} + + +def parse_note_to_frequency(note_str: str, octave: int) -> float: + """ + Parses a musical note string (e.g., `C#`, `A flat`, `db`) and an octave + to calculate its frequency in Hz. + + Args: + note_str (str): The note name. Case-insensitive. Handles sharps ( #), flats (b), + and text (`sharp`, `flat`). + octave (int): The octave number (e.g., 4 for middle C's octave). + + Returns: + float: The frequency of the note in Hz. + + Raises: + ValueError: If the note name is invalid. + """ + if not isinstance(note_str, str): + raise TypeError("Note name must be a string.") + + # Normalize the string: lowercase, remove "sharp" or "flat" text, remove spaces + processed_str = note_str.lower().strip() + processed_str = re.sub(r'\s*sharp\s*', '#', processed_str) + processed_str = re.sub(r'\s*flat\s*', 'b', processed_str) + processed_str = processed_str.replace(" ", "") + + if processed_str not in NOTES: + raise ValueError(f"Invalid note name: '{note_str}'. Could not parse to a valid note.") + + # Get the number of semitones away from A + semitone_offset_from_a = NOTES[processed_str] + + # Calculate the number of semitones away from A4 (A in the 4th octave) + # The note "A" in octave 4 is our base (0 semitones from itself). + # The note "A" in octave 5 is 12 semitones higher. + # The note "C" in octave 4 is -9 semitones from A4. + n = semitone_offset_from_a + (octave - 4) * 12 + + # Apply the frequency formula: f = f_base * (2^(1/12))^n + frequency = A4_FREQ * (2**(1/12))**n + + return frequency diff --git a/source/tests/test_audio_note_node.py b/source/tests/test_audio_note_node.py new file mode 100644 index 0000000..3048d21 --- /dev/null +++ b/source/tests/test_audio_note_node.py @@ -0,0 +1,37 @@ +""" +Regression tests for the AudioMusicalNote node in ComfyUI-AudioBatch. +""" + +import bootstrap # noqa: F401 +import pytest +from nodes.nodes_audio import AudioMusicalNote + + +@pytest.fixture +def note_node(): + return AudioMusicalNote() + + +def test_note_node_valid_input(note_node): + """Tests the node's integration with the parser for a valid note.""" + note = "C" + octave = 4 + + (frequency,) = note_node.get_frequency(note, octave) + + assert frequency == pytest.approx(261.63, rel=0.001) + + +def test_note_node_invalid_input(note_node): + """ + Tests the node's error handling for an invalid note. + It should not crash and should return a default frequency. + """ + note = "Z" # Invalid note + octave = 4 + + # The node should catch the ValueError from the parser and return a default + (frequency,) = note_node.get_frequency(note, octave) + + # Assert that it returned the default fallback frequency + assert frequency == 440.0 diff --git a/source/tests/test_misc_utils.py b/source/tests/test_misc_utils.py index b2b496f..3928a49 100644 --- a/source/tests/test_misc_utils.py +++ b/source/tests/test_misc_utils.py @@ -4,8 +4,8 @@ Tests for miscellaneous utility functions found in utils/misc.py. import bootstrap # noqa: F401 import pytest -# Import the function to be tested -from nodes.utils.misc import parse_time_to_seconds +# Import the functions to be tested +from nodes.utils.misc import parse_note_to_frequency, parse_time_to_seconds # --- Test Cases for parse_time_to_seconds --- @@ -87,3 +87,50 @@ def test_parse_time_non_string_input_raises_error(): with pytest.raises(TypeError): parse_time_to_seconds({"time": "10"}) # Pass a dict + + +# --- Test Cases for parse_note_to_frequency --- + +@pytest.mark.parametrize("note, octave, expected_freq", [ + # Reference notes + ("A", 4, 440.0), # A4 standard pitch + ("C", 4, 261.63), # Middle C + ("C", 0, 16.35), # Very low C + + # Different notations + ("C#", 4, 277.18), # C sharp + ("c sharp", 4, 277.18), # C sharp with text + ("c sharp", 4, 277.18), # C sharp with extra space + ("Db", 4, 277.18), # D flat (same as C#) + ("d flat", 4, 277.18), # D flat with text + ("d b", 4, 277.18), # D flat with extra space + + # Different octaves + ("A", 5, 880.0), # One octave higher + ("A", 3, 220.0), # One octave lower +]) +def test_parse_note_valid_inputs(note, octave, expected_freq): + """Tests that valid note strings are parsed to the correct frequencies.""" + assert parse_note_to_frequency(note, octave) == pytest.approx(expected_freq, rel=0.001) + + +@pytest.mark.parametrize("invalid_note_str", [ + "H", # Not a valid note letter + "C##", # Double sharp not supported by this simple parser + "Dbb", # Double flat not supported + "A Sharps", # Invalid text + "123", # Not a note + "", # Empty string +]) +def test_parse_note_invalid_notes_raise_error(invalid_note_str): + """Tests that invalid note names raise ValueError.""" + with pytest.raises(ValueError, match="Invalid note name"): + parse_note_to_frequency(invalid_note_str, 4) + + +def test_parse_note_invalid_type_raises_error(): + """Tests that non-string inputs raise TypeError.""" + with pytest.raises(TypeError): + parse_note_to_frequency(123, 4) + with pytest.raises(TypeError): + parse_note_to_frequency(None, 4)