diff --git a/README.md b/README.md index 12d9dfd..95abbbe 100644 --- a/README.md +++ b/README.md @@ -2,40 +2,64 @@ A token-efficient, unambiguous text-based musical chord notation system designed for seamless integration with Large Language Models. -## Overview +## Purpose -**Noten** (also known as LLM-Chart) is a chord progression format that balances three key requirements: -- **Unambiguous**: Clear rhythm alignment eliminates guesswork -- **Token-Efficient**: Concise syntax minimizes LLM context usage -- **Human-Readable**: Intuitive for musicians to read and write +**Noten** (also known as LLM-Chart) solves the problem of ambiguous and token-heavy musical notation in AI applications. Standard formats like MusicXML are too verbose for LLMs, while text formats like ChordPro lack rhythmic precision. -## Quick Start +Noten balances three key requirements: +- **Unambiguous**: Clear rhythm alignment eliminates guesswork. +- **Token-Efficient**: Concise syntax minimizes LLM context usage. +- **Human-Readable**: Intuitive for musicians to read and write. + +## Setup ### Installation -The noten package is now properly packaged and can be installed with `uv` (recommended) or `pip`: +The noten package is designed to be lightweight and easy to install. -```bash -# Install with uv (recommended - fast!) -uv pip install -e . +1. **Clone the repository:** + ```bash + git clone https://github.com/yourusername/noten.git + cd noten + ``` -# Or with traditional pip -pip install -e . -``` +2. **Install with `uv` (recommended) or `pip`:** + ```bash + # Install with uv (faster) + uv pip install -e . + + # Or with traditional pip + pip install -e . + ``` For detailed installation instructions, see [INSTALLATION.md](INSTALLATION.md). +### Running Tests + +To ensure everything is working correctly: + ```bash -# Run tests python test_noten.py +``` + +### Running Demos + +Explore the capabilities of Noten with the included demos: -# Run demos +```bash +# Simulated Reharmonization Demo (No API key needed) python demo_reharmonization.py -python demo_real_llm.py # Requires API keys + +# Real LLM Integration Demo (Requires API keys) +python demo_real_llm.py ``` +## Usage + ### Basic Example +A typical Noten file looks like this: + ``` {title: My Song} {key: C} @@ -48,6 +72,48 @@ python demo_real_llm.py # Requires API keys |: C . Am . | F . G . :| x2 ``` +### Python API + +You can easily integrate Noten into your Python projects. + +#### Parsing a Song + +```python +from noten import parse + +noten_string = """ +{time: 4/4} +| C . . G | Am . F . | +""" + +ast = parse(noten_string) +ast_dict = ast.to_dict() +# ast_dict now contains the Abstract Syntax Tree of the song +``` + +#### Rhythm Analysis + +To calculate the exact timing and duration of each chord: + +```python +from noten import parse, calculate_durations, print_rhythm_analysis + +noten = """ +{time: 4/4} +| C . . G | (Am G F) C | +""" + +ast = parse(noten) +events = calculate_durations(ast.to_dict()) + +# Print a formatted table +print_rhythm_analysis(events) + +# Access event data programmatically +for event in events: + print(f"Chord: {event['chord']['root']}, Start: {event['start']}, Duration: {event['duration']}") +``` + ## Format Specification ### Annotations diff --git a/demo_real_llm.py b/demo_real_llm.py index 5cd4133..1b6c3fa 100644 --- a/demo_real_llm.py +++ b/demo_real_llm.py @@ -20,12 +20,15 @@ def create_reharmonization_prompt(noten_input: str, style: str = "jazz") -> str: """ Create a prompt for LLM chord reharmonization. + Constructs a detailed prompt explaining the Noten format rules and the + desired reharmonization style. + Args: - noten_input: Original chord progression in noten format - style: Target style (jazz, bossa nova, gospel, etc.) + noten_input: Original chord progression in noten format. + style: Target style (e.g., "jazz", "bossa nova", "gospel"). Defaults to "jazz". Returns: - Formatted prompt for the LLM + str: A formatted prompt string ready to be sent to the LLM. """ prompt = f"""You are an expert music arranger. I'll provide a chord progression in "noten" format, and you should reharmonize it in {style} style. @@ -66,12 +69,18 @@ def call_llm(prompt: str, model: str = "claude-3-5-sonnet-20241022") -> str: """ Call LLM using litellm. + Sends the prompt to the specified model via the litellm library and returns + the generated content. + Args: - prompt: The prompt to send - model: Model identifier (e.g., "anthropic/claude-3-5-sonnet-20241022") + prompt: The prompt text to send to the LLM. + model: Model identifier (e.g., "anthropic/claude-3-5-sonnet-20241022"). Returns: - LLM response text + str: The response text from the LLM. + + Raises: + Exception: If the API call fails. """ print(f"\nšŸ¤– Calling LLM: {model}") print(f" (This may take 10-30 seconds...)") @@ -95,11 +104,17 @@ def extract_noten_from_response(response: str) -> str: """ Extract noten format from LLM response (in case there's extra text). + Attempts to locate the Noten code block within the LLM's response, handling + markdown code blocks and conversational text. + Args: - response: LLM response text + response: The full text response from the LLM. Returns: - Extracted noten format string + str: The extracted Noten format string. + + Raises: + ValueError: If no valid Noten format can be found in the response. """ # Remove markdown code blocks if present response = response.strip() @@ -143,12 +158,18 @@ def analyze_progression(noten_input: str, title: str = "Progression") -> tuple[O """ Parse and analyze a noten progression. + Parses the input string, extracts metadata, calculates rhythm, and prints + a summary of the analysis. + Args: - noten_input: Chord progression in noten format - title: Title for display + noten_input: Chord progression in noten format. + title: Title to display above the analysis. Returns: - Tuple of (ast_dict, events) or (None, None) if parsing fails + tuple: A tuple containing: + - ast_dict (Optional[Dict[str, Any]]): The parsed AST as a dictionary. + - events (Optional[list]): List of calculated rhythm events. + Returns (None, None) if parsing fails. """ print(f"\n{'═' * 70}") print(f"{title}") @@ -197,11 +218,11 @@ def analyze_progression(noten_input: str, title: str = "Progression") -> tuple[O def compare_progressions(original_events, reharmonized_events): """ - Compare original and reharmonized progressions. + Compare original and reharmonized progressions side-by-side. Args: - original_events: Rhythm events from original - reharmonized_events: Rhythm events from reharmonization + original_events: List of rhythm events from the original progression. + reharmonized_events: List of rhythm events from the reharmonized progression. """ print(f"\n{'═' * 70}") print("COMPARISON: Original vs Reharmonized") @@ -243,7 +264,13 @@ def compare_progressions(original_events, reharmonized_events): def demo_interactive(): """ - Interactive demo allowing user to choose options. + Run an interactive demo allowing the user to choose models and input options. + + This function handles: + 1. Checking for available API keys. + 2. Prompting the user to select an LLM model. + 3. Prompting the user to select an example or provide custom input. + 4. Running the reharmonization process and displaying results. """ print(""" ╔══════════════════════════════════════════════════════════════════╗ diff --git a/demo_reharmonization.py b/demo_reharmonization.py index 860c813..c3204b9 100644 --- a/demo_reharmonization.py +++ b/demo_reharmonization.py @@ -92,6 +92,9 @@ def analyze_progression(noten_input: str, title: str = "Progression"): Args: noten_input: Chord progression in noten format title: Title for display + + Returns: + Tuple containing the AST dictionary and the list of rhythm events, or (None, None) on failure. """ print(f"\n{'=' * 70}") print(f"{title}") @@ -138,11 +141,11 @@ def analyze_progression(noten_input: str, title: str = "Progression"): def compare_progressions(original_events, reharmonized_events): """ - Compare original and reharmonized progressions. + Compare original and reharmonized progressions side-by-side. Args: - original_events: Rhythm events from original - reharmonized_events: Rhythm events from reharmonization + original_events: List of rhythm events from the original progression. + reharmonized_events: List of rhythm events from the reharmonized progression. """ print(f"\n{'=' * 70}") print("COMPARISON") @@ -176,6 +179,13 @@ def compare_progressions(original_events, reharmonized_events): def demo_reharmonization(): """ Run the complete reharmonization demo. + + This function executes the full pipeline: + 1. Parsing an example original progression. + 2. Generating a reharmonization prompt. + 3. Simulating an LLM response (or calling one if configured). + 4. Parsing the reharmonized result. + 5. Comparing both versions. """ print(""" ╔══════════════════════════════════════════════════════════════════╗ @@ -269,7 +279,11 @@ def demo_reharmonization(): def main(): - """Main entry point for the demo.""" + """ + Main entry point for the demo script. + + Runs the reharmonization demo and prints instructions for real LLM integration. + """ demo_reharmonization() print("\nTo integrate with a real LLM:") diff --git a/src/noten/noten_lexer.py b/src/noten/noten_lexer.py index a516095..2c077c5 100644 --- a/src/noten/noten_lexer.py +++ b/src/noten/noten_lexer.py @@ -9,7 +9,27 @@ class TokenType(Enum): - """Token types as defined in Section 2.1 of the specification.""" + """ + Token types as defined in Section 2.1 of the specification. + + Attributes: + ANNOTATION_START: '{' - Starts an annotation. + ANNOTATION_END: '}' - Ends an annotation. + ANNOTATION_CONTENT: Content within an annotation block. + BAR_START: '|' - Starts a measure (and ends previous one). + BAR_END: '|' - Ends a measure (conceptually same as BAR_START). + REPEAT_START: '|:' - Starts a repeat section. + REPEAT_END: ':|' - Ends a repeat section. + CHORD: Musical chord symbol (e.g., "C", "Am7"). + NO_CHORD: "N.C." - Indicates no chord played. + CONTINUATION: '.' - Extends duration of previous chord. + TUPLET_START: '(' - Starts a tuplet group. + TUPLET_END: ')' - Ends a tuplet group. + SINGLE_REPEAT: '%' - Repeats previous measure. + MULTI_REPEAT: 'xN' or '%xN' - Repeats section or measure N times. + NEWLINE: Line break character. + WHITESPACE: Spaces or tabs. + """ ANNOTATION_START = auto() ANNOTATION_END = auto() ANNOTATION_CONTENT = auto() @@ -30,13 +50,27 @@ class TokenType(Enum): @dataclass class Token: - """Represents a single token from the input.""" + """ + Represents a single token from the input. + + Attributes: + type: The type of the token (e.g., CHORD, BAR_START). + value: The raw string value of the token. + line: The line number where the token appears (1-based). + column: The column number where the token starts (1-based). + """ type: TokenType value: str line: int column: int def __repr__(self): + """ + Return a string representation of the token. + + Returns: + String representation useful for debugging. + """ return f"Token({self.type.name}, {self.value!r}, line={self.line}, col={self.column})" @@ -46,6 +80,12 @@ class NotenLexer: Converts input string into a stream of tokens according to the specification in Section 2.1. + + Attributes: + input_text: The original input string. + tokens: The list of generated tokens. + current_line: Current line number during scanning. + current_column: Current column number during scanning. """ # Token patterns in priority order (more specific patterns first) @@ -91,7 +131,7 @@ def __init__(self, input_text: str): Initialize the lexer with input text. Args: - input_text: The noten format string to tokenize + input_text: The noten format string to tokenize. """ self.input_text = input_text self.tokens: List[Token] = [] @@ -102,8 +142,14 @@ def tokenize(self) -> List[Token]: """ Tokenize the entire input string. + Iterates through the input text, matching patterns to generate tokens. + Handles annotations by splitting them into component tokens. + Returns: - List of tokens + List[Token]: The list of generated tokens. + + Raises: + ValueError: If an unexpected character is encountered. """ position = 0 @@ -200,10 +246,11 @@ def get_tokens(self, include_whitespace: bool = False) -> List[Token]: Get the list of tokens, optionally filtering out whitespace. Args: - include_whitespace: If False, WHITESPACE tokens are excluded + include_whitespace: If False, WHITESPACE tokens are excluded from the result. + Defaults to False. Returns: - List of tokens + List[Token]: The filtered list of tokens. """ if include_whitespace: return self.tokens @@ -215,12 +262,18 @@ def tokenize(input_text: str, include_whitespace: bool = False) -> List[Token]: """ Convenience function to tokenize noten input. + Wraps the NotenLexer to provide a simple function call for tokenizing text. + Args: - input_text: The noten format string - include_whitespace: Whether to include whitespace tokens + input_text: The noten format string to be tokenized. + include_whitespace: Whether to include whitespace tokens in the output list. + Defaults to False. Returns: - List of tokens + List[Token]: A list of Token objects representing the input string. + + Raises: + ValueError: If an unknown character is encountered. """ lexer = NotenLexer(input_text) lexer.tokenize() diff --git a/src/noten/noten_parser.py b/src/noten/noten_parser.py index 8756fc1..a053900 100644 --- a/src/noten/noten_parser.py +++ b/src/noten/noten_parser.py @@ -12,13 +12,27 @@ @dataclass class ChordNode: - """Represents a single chord.""" + """ + Represents a single chord. + + Attributes: + type: Node type identifier (always "Chord"). + root: The root note of the chord (e.g., "C", "F#"). + quality: The chord quality and extensions (e.g., "maj7", "m"). + bass: Optional bass note for slash chords (e.g., "B" in "G/B"). + """ type: str = "Chord" root: str = "" quality: str = "" bass: Optional[str] = None def to_dict(self) -> Dict[str, Any]: + """ + Convert the chord node to a dictionary representation. + + Returns: + A dictionary containing the chord's type, root, quality, and bass. + """ return { "type": self.type, "root": self.root, @@ -29,20 +43,47 @@ def to_dict(self) -> Dict[str, Any]: @dataclass class ContinuationNode: - """Represents a continuation marker (.).""" + """ + Represents a continuation marker (.). + + This node indicates that the previous chord's duration should be extended. + + Attributes: + type: Node type identifier (always "Continuation"). + """ type: str = "Continuation" def to_dict(self) -> Dict[str, Any]: + """ + Convert the continuation node to a dictionary representation. + + Returns: + A dictionary containing the node type. + """ return {"type": self.type} @dataclass class TupletNode: - """Represents a tuplet group of chords.""" + """ + Represents a tuplet group of chords. + + A tuplet groups multiple chords that share a single beat's duration. + + Attributes: + type: Node type identifier (always "Tuplet"). + chords: List of ChordNodes within this tuplet. + """ type: str = "Tuplet" chords: List[ChordNode] = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: + """ + Convert the tuplet node to a dictionary representation. + + Returns: + A dictionary containing the node type and list of chord dictionaries. + """ return { "type": self.type, "chords": [c.to_dict() for c in self.chords] @@ -51,11 +92,23 @@ def to_dict(self) -> Dict[str, Any]: @dataclass class MeasureNode: - """Represents a standard measure with beat markers.""" + """ + Represents a standard measure with beat markers. + + Attributes: + type: Node type identifier (always "Measure"). + beats: List of beat elements (ChordNode, TupletNode, or ContinuationNode). + """ type: str = "Measure" beats: List[Any] = field(default_factory=list) # ChordNode, TupletNode, or ContinuationNode def to_dict(self) -> Dict[str, Any]: + """ + Convert the measure node to a dictionary representation. + + Returns: + A dictionary containing the node type and list of beat dictionaries. + """ return { "type": self.type, "beats": [b.to_dict() for b in self.beats] @@ -64,11 +117,25 @@ def to_dict(self) -> Dict[str, Any]: @dataclass class RepeatMeasureNode: - """Represents a measure repeat (% or %xN).""" + """ + Represents a measure repeat (% or %xN). + + Indicates that the previous measure content should be repeated. + + Attributes: + type: Node type identifier (always "RepeatMeasure"). + count: Number of times to repeat (default is 1). + """ type: str = "RepeatMeasure" count: int = 1 def to_dict(self) -> Dict[str, Any]: + """ + Convert the repeat measure node to a dictionary representation. + + Returns: + A dictionary containing the node type and repeat count. + """ return { "type": self.type, "count": self.count @@ -77,12 +144,27 @@ def to_dict(self) -> Dict[str, Any]: @dataclass class RepeatSectionNode: - """Represents a repeat section (|: ... :|).""" + """ + Represents a repeat section (|: ... :|). + + Contains a sequence of measures that are repeated a specified number of times. + + Attributes: + type: Node type identifier (always "RepeatSection"). + measures: List of measures within the repeat section. + repeat_count: Total number of times this section is played (default is 1). + """ type: str = "RepeatSection" measures: List[Any] = field(default_factory=list) # MeasureNode or RepeatMeasureNode repeat_count: int = 1 def to_dict(self) -> Dict[str, Any]: + """ + Convert the repeat section node to a dictionary representation. + + Returns: + A dictionary containing the node type, repeat count, and list of measure dictionaries. + """ return { "type": self.type, "repeatCount": self.repeat_count, @@ -92,11 +174,25 @@ def to_dict(self) -> Dict[str, Any]: @dataclass class MeasureLineNode: - """Represents a line of measures.""" + """ + Represents a line of measures. + + This corresponds to a single line of input text containing one or more measures. + + Attributes: + type: Node type identifier (always "MeasureLine"). + measures: List of measures (MeasureNode, RepeatMeasureNode, or RepeatSectionNode). + """ type: str = "MeasureLine" measures: List[Any] = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: + """ + Convert the measure line node to a dictionary representation. + + Returns: + A dictionary containing the node type and list of measure dictionaries. + """ return { "type": self.type, "measures": [m.to_dict() for m in self.measures] @@ -105,11 +201,25 @@ def to_dict(self) -> Dict[str, Any]: @dataclass class AnnotationNode: - """Represents an annotation block.""" + """ + Represents an annotation block. + + Annotations are metadata or directives enclosed in curly braces, e.g., {title: ...}. + + Attributes: + type: Node type identifier (always "Annotation"). + content: The text content inside the annotation braces. + """ type: str = "Annotation" content: str = "" def to_dict(self) -> Dict[str, Any]: + """ + Convert the annotation node to a dictionary representation. + + Returns: + A dictionary containing the node type and annotation content. + """ return { "type": self.type, "content": self.content @@ -118,11 +228,23 @@ def to_dict(self) -> Dict[str, Any]: @dataclass class SongNode: - """Root AST node representing the entire song.""" + """ + Root AST node representing the entire song. + + Attributes: + type: Node type identifier (always "Song"). + body: List of top-level nodes (AnnotationNode or MeasureLineNode). + """ type: str = "Song" body: List[Any] = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: + """ + Convert the song node to a dictionary representation. + + Returns: + A dictionary containing the node type and list of body element dictionaries. + """ return { "type": self.type, "body": [node.to_dict() for node in self.body] @@ -132,7 +254,9 @@ def to_dict(self) -> Dict[str, Any]: class ChordParser: """ Parses chord symbols into root, quality, and bass components. - Fixes the spec issue where "Am" was shown as root="Am", quality="". + + This utility class handles the complexities of chord syntax, including + slashes for bass notes and special cases like "N.C.". """ @staticmethod @@ -149,10 +273,13 @@ def parse(chord_str: str) -> ChordNode: "N.C." -> root="N.C.", quality="", bass=None Args: - chord_str: The chord symbol string + chord_str: The chord symbol string to parse. Returns: - ChordNode with parsed components + ChordNode: A ChordNode object containing the parsed root, quality, and bass. + + Raises: + ValueError: If the chord string is invalid or cannot be parsed. """ # Special case: No Chord if chord_str == "N.C.": @@ -182,6 +309,10 @@ class NotenParser: Consumes tokens and builds an Abstract Syntax Tree according to the grammar in Section 3.1 of the specification. + + Attributes: + tokens: The list of tokens to parse. + position: The current index in the token list. """ def __init__(self, tokens: List[Token]): @@ -189,7 +320,7 @@ def __init__(self, tokens: List[Token]): Initialize the parser with a token stream. Args: - tokens: List of tokens from the lexer + tokens: List of tokens from the lexer. """ self.tokens = tokens self.position = 0 @@ -198,8 +329,14 @@ def parse(self) -> SongNode: """ Parse the token stream into an AST. + Iterates through the tokens and constructs the song structure, + including annotations and measure lines. + Returns: - SongNode representing the entire song + SongNode: The root node representing the entire song. + + Raises: + ValueError: If an unexpected token is encountered. """ song = SongNode() @@ -496,11 +633,17 @@ def parse(input_text: str) -> SongNode: """ Convenience function to parse noten input directly from text. + This is the main entry point for parsing Noten format strings. It handles + tokenization and parsing in a single step. + Args: - input_text: The noten format string + input_text: The noten format string to be parsed. Returns: - SongNode AST + SongNode: The root node of the resulting Abstract Syntax Tree (AST). + + Raises: + ValueError: If parsing fails due to invalid syntax or unexpected tokens. """ tokens = tokenize(input_text, include_whitespace=False) parser = NotenParser(tokens) diff --git a/src/noten/noten_rhythm.py b/src/noten/noten_rhythm.py index decda57..69053a2 100644 --- a/src/noten/noten_rhythm.py +++ b/src/noten/noten_rhythm.py @@ -7,7 +7,11 @@ class TimeSignature: - """Represents a time signature like 4/4, 3/4, 6/8, etc.""" + """ + Represents a time signature like 4/4, 3/4, 6/8, etc. + + Handles parsing of time signature strings and calculation of beats per measure. + """ def __init__(self, signature_str: str = "4/4"): """ @@ -38,6 +42,12 @@ def beats_per_measure(self) -> Fraction: return Fraction(self.numerator, 1) def __str__(self): + """ + Return the string representation of the time signature. + + Returns: + String like "4/4", "3/4", etc. + """ return f"{self.numerator}/{self.denominator}" @@ -49,6 +59,10 @@ class RhythmCalculator: 1. Time is divided equally among top-level elements in a measure 2. Continuation markers extend the previous chord's duration 3. Tuplets are single top-level elements with internal subdivision + + Attributes: + time_signature: The current time signature being used. + last_chord_event: Tracks the last chord event to handle continuations across boundaries. """ def __init__(self, time_signature: TimeSignature = None): @@ -56,7 +70,7 @@ def __init__(self, time_signature: TimeSignature = None): Initialize the rhythm calculator. Args: - time_signature: The time signature to use (defaults to 4/4) + time_signature: The initial time signature to use. Defaults to 4/4 if None. """ self.time_signature = time_signature or TimeSignature("4/4") self.last_chord_event = None # Track the last chord across measures @@ -65,13 +79,15 @@ def calculate_song_durations(self, song_ast: Dict[str, Any]) -> List[Dict[str, A """ Calculate durations for all chords in a song AST. - Returns a flat list of chord events with timing information. + Processes the song structure, updating time signatures from annotations + and calculating start times and durations for every chord event. Args: - song_ast: The song AST dictionary + song_ast: The song AST dictionary (SongNode.to_dict()). Returns: - List of chord events with start time and duration + List[Dict[str, Any]]: A flat list of chord events with timing information. + Each event includes 'chord', 'start', 'duration'. """ events = [] current_time = Fraction(0) @@ -240,12 +256,16 @@ def calculate_durations(song_ast: Dict[str, Any], time_signature: str = "4/4") - """ Convenience function to calculate durations for a song AST. + Initializes a RhythmCalculator and processes the provided AST. + Args: - song_ast: The song AST dictionary - time_signature: Time signature string (default "4/4") + song_ast: The song AST dictionary (as returned by SongNode.to_dict()). + time_signature: Time signature string (default "4/4") to use if not specified + in the song annotations. Returns: - List of chord events with timing information + List[Dict[str, Any]]: List of chord events, where each event is a dictionary + containing 'chord', 'start', 'duration', etc. """ calculator = RhythmCalculator(TimeSignature(time_signature)) return calculator.calculate_song_durations(song_ast) @@ -253,10 +273,13 @@ def calculate_durations(song_ast: Dict[str, Any], time_signature: str = "4/4") - def print_rhythm_analysis(events: List[Dict[str, Any]]): """ - Pretty-print rhythm analysis. + Pretty-print rhythm analysis to standard output. + + Formats the list of chord events into a readable table showing time, + duration, and chord details. Args: - events: List of chord events + events: List of chord events (as returned by calculate_durations). """ print(f"{'Time':<10} {'Duration':<12} {'Chord':<15} {'Tuplet'}") print("-" * 60) diff --git a/test_noten.py b/test_noten.py index 4c4ba64..4f82548 100644 --- a/test_noten.py +++ b/test_noten.py @@ -8,7 +8,12 @@ def test_chord_parser(): - """Test chord parsing into root, quality, bass components.""" + """ + Test chord parsing into root, quality, bass components. + + Verifies that various chord strings (maj7, minor, slash chords, etc.) + are correctly parsed into their constituent parts. + """ test_cases = [ ("C", {"root": "C", "quality": "", "bass": None}), @@ -33,7 +38,12 @@ def test_chord_parser(): def test_basic_parsing(): - """Test basic parsing functionality.""" + """ + Test basic parsing functionality. + + Verifies that a simple song structure with annotations and a measure line + is parsed correctly into the expected AST structure. + """ input_text = """{title: Test Song} {key: G} {time: 4/4} @@ -62,7 +72,12 @@ def test_basic_parsing(): def test_tuplet_parsing(): - """Test tuplet parsing.""" + """ + Test tuplet parsing. + + Verifies that tuplets (grouped chords within parentheses) are correctly + identified and parsed into TupletNodes. + """ input_text = "| C (G F Em) D |" ast = parse(input_text) @@ -85,7 +100,12 @@ def test_tuplet_parsing(): def test_repeat_section(): - """Test repeat section parsing.""" + """ + Test repeat section parsing. + + Verifies that repeat sections with explicit repeat counts (e.g., |: ... :| x3) + are correctly parsed into RepeatSectionNodes. + """ input_text = "|: G | C :| x3" ast = parse(input_text) @@ -100,7 +120,12 @@ def test_repeat_section(): def test_continuation_rhythm(): - """Test rhythm calculation with continuation markers.""" + """ + Test rhythm calculation with continuation markers. + + Verifies that continuation markers ('.') correctly extend the duration + of the preceding chord in the rhythm analysis. + """ # Example from spec: | C . . G | has Cmaj7 for 3 beats, G for 1 beat input_text = """{time: 4/4} | C . . G | @@ -117,7 +142,11 @@ def test_continuation_rhythm(): def test_tuplet_rhythm(): - """Test rhythm calculation for tuplets.""" + """ + Test rhythm calculation for tuplets. + + Verifies that chords within a tuplet share the beat duration equally. + """ # Example from spec: | C (G F G) | - two top-level elements input_text = """{time: 4/4} | C (G F G) | @@ -138,7 +167,12 @@ def test_tuplet_rhythm(): def test_slash_chords(): - """Test slash chord parsing.""" + """ + Test slash chord parsing. + + Verifies that slash chords (e.g., G/B) are correctly parsed, separating + the root/quality from the bass note. + """ input_text = "| G/B | Am7/G | C |" ast = parse(input_text) @@ -158,7 +192,12 @@ def test_slash_chords(): def test_different_time_signatures(): - """Test rhythm calculation with different time signatures.""" + """ + Test rhythm calculation with different time signatures. + + Verifies that rhythm calculations adjust correctly for different time + signatures like 3/4 and 6/8. + """ # 3/4 time input_text = """{time: 3/4} | C G Am | @@ -188,7 +227,12 @@ def test_different_time_signatures(): def test_no_chord(): - """Test N.C. (no chord) parsing.""" + """ + Test N.C. (no chord) parsing. + + Verifies that 'N.C.' is correctly recognized as a valid chord token + representing silence or no chord. + """ input_text = "| C . N.C. . |" ast = parse(input_text)