[Added][Audio Musical Note] With test and example
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@ import torchaudio
|
||||
import torchaudio.transforms as T
|
||||
from .utils.aligner import AudioBatchAligner
|
||||
from .utils.logger import main_logger
|
||||
from .utils.misc import parse_time_to_seconds
|
||||
from .utils.misc import parse_time_to_seconds, parse_note_to_frequency
|
||||
|
||||
logger = main_logger
|
||||
BASE_CATEGORY = "audio"
|
||||
@@ -619,3 +619,43 @@ class AudioTestSignalGenerator:
|
||||
}
|
||||
|
||||
return (output_audio,)
|
||||
|
||||
|
||||
class AudioMusicalNote:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"note": ("STRING", {
|
||||
"default": "A",
|
||||
"tooltip": "The musical note in American notation (e.g., C, F#, Gb, 'D sharp'). Case-insensitive."
|
||||
}),
|
||||
"octave": ("INT", {
|
||||
"default": 4,
|
||||
"min": 0,
|
||||
"max": 8, # Standard piano range is roughly 0-8
|
||||
"step": 1,
|
||||
"tooltip": "The octave number (e.g., 4 corresponds to middle C's octave)."
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("FLOAT",)
|
||||
RETURN_NAMES = ("frequency",)
|
||||
FUNCTION = "get_frequency"
|
||||
CATEGORY = BASE_CATEGORY + "/" + GEN_CATEGORY
|
||||
DESCRIPTION = "Converts a musical note and octave to its corresponding frequency in Hz."
|
||||
UNIQUE_NAME = "SET_AudioMusicalNote"
|
||||
DISPLAY_NAME = "Audio Musical Note"
|
||||
|
||||
def get_frequency(self, note: str, octave: int):
|
||||
try:
|
||||
frequency = parse_note_to_frequency(note, octave)
|
||||
logger.info(f"Parsed note '{note}{octave}' to {frequency:.2f} Hz.")
|
||||
return (frequency,)
|
||||
except (ValueError, TypeError) as e:
|
||||
# If the user enters an invalid note, log it as a warning
|
||||
# and return a default safe frequency (e.g., A4) to avoid crashing.
|
||||
# A UI warning could also be sent if this were a generator.
|
||||
logger.error(f"Error parsing note: {e}. Defaulting to 440.0 Hz.")
|
||||
return (440.0,)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial
|
||||
# License: GPL-3.0
|
||||
# Project: ComfyUI-AudioBatch
|
||||
import re
|
||||
|
||||
NODES_NAME = "AudioBatch"
|
||||
NODES_DEBUG_VAR = NODES_NAME.upper() + "_NODES_DEBUG"
|
||||
@@ -65,3 +66,59 @@ def parse_time_to_seconds(time_str: str) -> 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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user