diff --git a/config.yml.example b/config.yml.example index 8c80a18c578863ac482eb0efef3a822aa948ddb8..2385e8e8b87b34143cc0388cf9727752f34fb709 100644 --- a/config.yml.example +++ b/config.yml.example @@ -9,12 +9,17 @@ audio: f0_max: 880 device: "cpu" -selected_song: "fly_me_to_the_moon" +interval_practice: + num_problems: 10 + interval: 1 -chord_progressions: - fly_me_to_the_moon: - - ["A", "natural_minor", "A", "min7", 4] - - ["A", "natural_minor", "D", "min7", 4] - - ["C", "major", "G", "dom7", 4] - - ["C", "major", "C", "maj7", 2] - - ["F", "major", "C", "dom7", 2] +piece_practice: + selected_song: "fly_me_to_the_moon" + + chord_progressions: + fly_me_to_the_moon: + - ["A", "natural_minor", "A", "min7", 4] + - ["A", "natural_minor", "D", "min7", 4] + - ["C", "major", "G", "dom7", 4] + - ["C", "major", "C", "maj7", 2] + - ["F", "major", "C", "dom7", 2] diff --git a/improvisation_lab/application/__init__.py b/improvisation_lab/application/__init__.py index beb529f1dd03e6afe4126a7eeea6166c959966dd..a29553b688b1beb22dd9fb6c8e8fe1b9985cb077 100644 --- a/improvisation_lab/application/__init__.py +++ b/improvisation_lab/application/__init__.py @@ -1 +1,5 @@ """Application layer for the Improvisation Lab.""" + +from improvisation_lab.application.app_factory import PracticeAppFactory + +__all__ = ["PracticeAppFactory"] diff --git a/improvisation_lab/application/app_factory.py b/improvisation_lab/application/app_factory.py new file mode 100644 index 0000000000000000000000000000000000000000..1971d49bbec420b1db86c0df6d5508dc9917c41a --- /dev/null +++ b/improvisation_lab/application/app_factory.py @@ -0,0 +1,43 @@ +"""Factory class for creating melody practice applications.""" + +from improvisation_lab.application.interval_practice import ( + ConsoleIntervalPracticeApp, WebIntervalPracticeApp) +from improvisation_lab.application.piece_practice import ( + ConsolePiecePracticeApp, WebPiecePracticeApp) +from improvisation_lab.config import Config +from improvisation_lab.service import (IntervalPracticeService, + PiecePracticeService) + + +class PracticeAppFactory: + """Factory class for creating melody practice applications.""" + + @staticmethod + def create_app(app_type: str, practice_type: str, config: Config): + """Create a melody practice application. + + Args: + app_type: Type of application to create. + practice_type: Type of practice to create. + config: Config instance. + """ + if app_type == "web": + if practice_type == "piece": + service = PiecePracticeService(config) + return WebPiecePracticeApp(service, config) + elif practice_type == "interval": + service = IntervalPracticeService(config) + return WebIntervalPracticeApp(service, config) + else: + raise ValueError(f"Unknown practice type: {practice_type}") + elif app_type == "console": + if practice_type == "piece": + service = PiecePracticeService(config) + return ConsolePiecePracticeApp(service, config) + elif practice_type == "interval": + service = IntervalPracticeService(config) + return ConsoleIntervalPracticeApp(service, config) + else: + raise ValueError(f"Unknown practice type: {practice_type}") + else: + raise ValueError(f"Unknown app type: {app_type}") diff --git a/improvisation_lab/application/base_app.py b/improvisation_lab/application/base_app.py new file mode 100644 index 0000000000000000000000000000000000000000..1fa79efe8a1b7ceb4e5883f3902e69f4abc1095e --- /dev/null +++ b/improvisation_lab/application/base_app.py @@ -0,0 +1,51 @@ +"""Base class for melody practice applications.""" + +from abc import ABC, abstractmethod +from typing import List, Optional + +import numpy as np + +from improvisation_lab.config import Config +from improvisation_lab.domain.composition import PhraseData +from improvisation_lab.service.base_practice_service import BasePracticeService + + +class BasePracticeApp(ABC): + """Base class for melody practice applications.""" + + def __init__(self, service: BasePracticeService, config: Config): + """Initialize the application. + + Args: + service: BasePracticeService instance. + config: Config instance. + """ + self.service = service + self.config = config + self.phrases: Optional[List[PhraseData]] = None + self.current_phrase_idx: int = 0 + self.current_note_idx: int = 0 + self.is_running: bool = False + + @abstractmethod + def _process_audio_callback(self, audio_data: np.ndarray): + """Process incoming audio data and update the application state. + + Args: + audio_data: Audio data to process. + """ + pass + + @abstractmethod + def _advance_to_next_note(self): + """Advance to the next note or phrase.""" + pass + + @abstractmethod + def launch(self, **kwargs): + """Launch the application. + + Args: + **kwargs: Additional keyword arguments for the launch method. + """ + pass diff --git a/improvisation_lab/application/base_console_app.py b/improvisation_lab/application/base_console_app.py new file mode 100644 index 0000000000000000000000000000000000000000..02a0cd7ca052b487d5a546aaa3d66632bc96f863 --- /dev/null +++ b/improvisation_lab/application/base_console_app.py @@ -0,0 +1,97 @@ +"""Console application for all practices.""" + +import time +from abc import ABC, abstractmethod +from typing import Optional + +import numpy as np + +from improvisation_lab.application.base_app import BasePracticeApp +from improvisation_lab.config import Config +from improvisation_lab.infrastructure.audio import DirectAudioProcessor +from improvisation_lab.presentation.console_view import ConsolePracticeView +from improvisation_lab.service.base_practice_service import BasePracticeService + + +class ConsoleBasePracticeApp(BasePracticeApp, ABC): + """Console application class for all practices.""" + + def __init__(self, service: BasePracticeService, config: Config): + """Initialize the application using console UI. + + Args: + service: PracticeService instance. + config: Config instance. + """ + super().__init__(service, config) + + self.audio_processor = DirectAudioProcessor( + sample_rate=config.audio.sample_rate, + callback=self._process_audio_callback, + buffer_duration=config.audio.buffer_duration, + ) + self.ui: Optional[ConsolePracticeView] = None + + def _process_audio_callback(self, audio_data: np.ndarray): + """Process incoming audio data and update the application state. + + Args: + audio_data: Audio data to process. + """ + if self.phrases is None: + return + current_note = self._get_current_note() + + result = self.service.process_audio(audio_data, current_note) + if self.ui is not None: + self.ui.display_pitch_result(result) + + # Progress to next note if current note is complete + if result.remaining_time <= 0: + self._advance_to_next_note() + + def _advance_to_next_note(self): + """Advance to the next note or phrase.""" + if self.phrases is None: + return + self.current_note_idx += 1 + if self.current_note_idx >= len(self._get_current_phrase()): + self.current_note_idx = 0 + self.current_phrase_idx += 1 + if self.current_phrase_idx >= len(self.phrases): + self.current_phrase_idx = 0 + self.ui.display_phrase_info(self.current_phrase_idx, self.phrases) + + def launch(self): + """Launch the application.""" + self.ui.launch() + self.phrases = self._generate_melody() + self.current_phrase_idx = 0 + self.current_note_idx = 0 + self.is_running = True + + if not self.audio_processor.is_recording: + try: + self.audio_processor.start_recording() + self.ui.display_phrase_info(self.current_phrase_idx, self.phrases) + while True: + time.sleep(0.1) + except KeyboardInterrupt: + print("\nStopping...") + finally: + self.audio_processor.stop_recording() + + @abstractmethod + def _get_current_note(self): + """Return the current note to be processed.""" + pass + + @abstractmethod + def _get_current_phrase(self): + """Return the current phrase to be processed.""" + pass + + @abstractmethod + def _generate_melody(self): + """Generate melody specific to the practice type.""" + pass diff --git a/improvisation_lab/application/interval_practice/__init__.py b/improvisation_lab/application/interval_practice/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3dc2ccfd71de22cc3f692ceac6c55cdcb633296e --- /dev/null +++ b/improvisation_lab/application/interval_practice/__init__.py @@ -0,0 +1,6 @@ +from improvisation_lab.application.interval_practice.console_interval_app import \ + ConsoleIntervalPracticeApp +from improvisation_lab.application.interval_practice.web_interval_app import \ + WebIntervalPracticeApp + +__all__ = ["WebIntervalPracticeApp", "ConsoleIntervalPracticeApp"] diff --git a/improvisation_lab/application/interval_practice/console_interval_app.py b/improvisation_lab/application/interval_practice/console_interval_app.py new file mode 100644 index 0000000000000000000000000000000000000000..1912a65fa3103acbb85447707f48dd837367fdaf --- /dev/null +++ b/improvisation_lab/application/interval_practice/console_interval_app.py @@ -0,0 +1,49 @@ +"""Console application for interval practice.""" + +from typing import List + +from improvisation_lab.application.base_console_app import \ + ConsoleBasePracticeApp +from improvisation_lab.config import Config +from improvisation_lab.domain.music_theory import Notes +from improvisation_lab.presentation.interval_practice import ( + ConsoleIntervalPracticeView, IntervalViewTextManager) +from improvisation_lab.service import IntervalPracticeService + + +class ConsoleIntervalPracticeApp(ConsoleBasePracticeApp): + """Console application class for interval practice.""" + + def __init__(self, service: IntervalPracticeService, config: Config): + """Initialize the application using console UI. + + Args: + service: IntervalPracticeService instance. + config: Config instance. + """ + super().__init__(service, config) + self.text_manager = IntervalViewTextManager() + self.ui = ConsoleIntervalPracticeView(self.text_manager) + + def _get_current_note(self) -> str: + """Return the current note to be processed. + + Returns: + The current note to be processed. + """ + return self.phrases[self.current_phrase_idx][self.current_note_idx].value + + def _get_current_phrase(self) -> List[Notes]: + """Return the current phrase to be processed.""" + return self.phrases[self.current_phrase_idx] + + def _generate_melody(self) -> List[List[Notes]]: + """Generate melody specific to the practice type. + + Returns: + The generated melody. + """ + return self.service.generate_melody( + num_notes=self.config.interval_practice.num_problems, + interval=self.config.interval_practice.interval, + ) diff --git a/improvisation_lab/application/interval_practice/web_interval_app.py b/improvisation_lab/application/interval_practice/web_interval_app.py new file mode 100644 index 0000000000000000000000000000000000000000..31a8ddf875d66ac4b600c3ab499c80a791417059 --- /dev/null +++ b/improvisation_lab/application/interval_practice/web_interval_app.py @@ -0,0 +1,164 @@ +"""Web application for interval practice.""" + +from typing import Tuple + +import numpy as np + +from improvisation_lab.application.base_app import BasePracticeApp +from improvisation_lab.config import Config +from improvisation_lab.domain.music_theory import Intervals +from improvisation_lab.infrastructure.audio import WebAudioProcessor +from improvisation_lab.presentation.interval_practice import ( + IntervalViewTextManager, WebIntervalPracticeView) +from improvisation_lab.service import IntervalPracticeService + + +class WebIntervalPracticeApp(BasePracticeApp): + """Web application class for interval practice.""" + + def __init__(self, service: IntervalPracticeService, config: Config): + """Initialize the application using web UI. + + Args: + service: IntervalPracticeService instance. + config: Config instance. + """ + super().__init__(service, config) + + self.audio_processor = WebAudioProcessor( + sample_rate=config.audio.sample_rate, + callback=self._process_audio_callback, + buffer_duration=config.audio.buffer_duration, + ) + + self.text_manager = IntervalViewTextManager() + self.ui = WebIntervalPracticeView( + on_generate_melody=self.start, + on_end_practice=self.stop, + on_audio_input=self.handle_audio, + config=config, + ) + self.base_note = "-" + + def _process_audio_callback(self, audio_data: np.ndarray): + """Process incoming audio data and update the application state. + + Args: + audio_data: Audio data to process. + """ + if not self.is_running or not self.phrases: + return + + current_note = self.phrases[self.current_phrase_idx][ + self.current_note_idx + ].value + + result = self.service.process_audio(audio_data, current_note) + + # Update status display + self.text_manager.update_pitch_result(result) + + # Progress to next note if current note is complete + if result.remaining_time <= 0: + self._advance_to_next_note() + + self.text_manager.update_phrase_text(self.current_phrase_idx, self.phrases) + + def _advance_to_next_note(self): + """Advance to the next note or phrase.""" + if self.phrases is None: + return + self.current_note_idx += 1 + if self.current_note_idx >= len(self.phrases[self.current_phrase_idx]): + self.current_note_idx = 0 + self.current_phrase_idx += 1 + if self.current_phrase_idx >= len(self.phrases): + self.current_phrase_idx = 0 + self.base_note = self.phrases[self.current_phrase_idx][ + self.current_note_idx + ].value + + def handle_audio(self, audio: Tuple[int, np.ndarray]) -> Tuple[str, str, str]: + """Handle audio input from Gradio interface. + + Args: + audio: Audio data to process. + + Returns: + Tuple[str, str, str]: + The current base note including the next base note, + target note, and result text. + """ + if not self.is_running: + return "-", "Not running", "Start the session first" + + self.audio_processor.process_audio(audio) + return ( + self.base_note, + self.text_manager.phrase_text, + self.text_manager.result_text, + ) + + def start( + self, interval: str, direction: str, number_problems: int + ) -> Tuple[str, str, str]: + """Start a new practice session. + + Args: + interval: Interval to move to and back. + direction: Direction to move to and back. + number_problems: Number of problems to generate. + + Returns: + Tuple[str, str, str]: + The current base note including the next base note, + target note, and result text. + """ + semitone_interval = Intervals.INTERVALS_MAP.get(interval, 0) + + if direction == "Down": + semitone_interval = -semitone_interval + self.phrases = self.service.generate_melody( + num_notes=number_problems, interval=semitone_interval + ) + self.current_phrase_idx = 0 + self.current_note_idx = 0 + self.is_running = True + + present_note = self.phrases[0][0].value + self.base_note = present_note + + if not self.audio_processor.is_recording: + self.text_manager.initialize_text() + self.audio_processor.start_recording() + + self.text_manager.update_phrase_text(self.current_phrase_idx, self.phrases) + + return ( + self.base_note, + self.text_manager.phrase_text, + self.text_manager.result_text, + ) + + def stop(self) -> Tuple[str, str, str]: + """Stop the current practice session. + + Returns: + tuple[str, str, str]: + The current base note including the next base note, + target note, and result text. + """ + self.is_running = False + self.base_note = "-" + if self.audio_processor.is_recording: + self.audio_processor.stop_recording() + self.text_manager.terminate_text() + return ( + self.base_note, + self.text_manager.phrase_text, + self.text_manager.result_text, + ) + + def launch(self, **kwargs): + """Launch the application.""" + self.ui.launch(**kwargs) diff --git a/improvisation_lab/application/piece_practice/__init__.py b/improvisation_lab/application/piece_practice/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..84a73c3d137cdd9bc794faf5edd779442cdf45ec --- /dev/null +++ b/improvisation_lab/application/piece_practice/__init__.py @@ -0,0 +1,8 @@ +"""Application layer for piece practice.""" + +from improvisation_lab.application.piece_practice.console_piece_app import \ + ConsolePiecePracticeApp +from improvisation_lab.application.piece_practice.web_piece_app import \ + WebPiecePracticeApp + +__all__ = ["ConsolePiecePracticeApp", "WebPiecePracticeApp"] diff --git a/improvisation_lab/application/piece_practice/console_piece_app.py b/improvisation_lab/application/piece_practice/console_piece_app.py new file mode 100644 index 0000000000000000000000000000000000000000..c6f2ff54435625a1a9b0652ea0a407a8211dfb05 --- /dev/null +++ b/improvisation_lab/application/piece_practice/console_piece_app.py @@ -0,0 +1,38 @@ +"""Console application for piece practice.""" + +from improvisation_lab.application.base_console_app import \ + ConsoleBasePracticeApp +from improvisation_lab.config import Config +from improvisation_lab.presentation.piece_practice import ( + ConsolePiecePracticeView, PieceViewTextManager) +from improvisation_lab.service import PiecePracticeService + + +class ConsolePiecePracticeApp(ConsoleBasePracticeApp): + """Console application class for piece practice.""" + + def __init__(self, service: PiecePracticeService, config: Config): + """Initialize the application using console UI. + + Args: + service: PiecePracticeService instance. + config: Config instance. + """ + super().__init__(service, config) + self.text_manager = PieceViewTextManager() + self.ui = ConsolePiecePracticeView( + self.text_manager, config.piece_practice.selected_song + ) + + def _get_current_note(self): + """Return the current note to be processed.""" + current_phrase = self.phrases[self.current_phrase_idx] + return current_phrase.notes[self.current_note_idx] + + def _get_current_phrase(self): + """Return the current phrase to be processed.""" + return self.phrases[self.current_phrase_idx].notes + + def _generate_melody(self): + """Generate melody specific to the practice type.""" + return self.service.generate_melody() diff --git a/improvisation_lab/application/piece_practice/web_piece_app.py b/improvisation_lab/application/piece_practice/web_piece_app.py new file mode 100644 index 0000000000000000000000000000000000000000..f8f164659a28456969b8bb715b363854573a5e56 --- /dev/null +++ b/improvisation_lab/application/piece_practice/web_piece_app.py @@ -0,0 +1,122 @@ +"""Web application for melody practice.""" + +from typing import Tuple + +import numpy as np + +from improvisation_lab.application.base_app import BasePracticeApp +from improvisation_lab.config import Config +from improvisation_lab.infrastructure.audio import WebAudioProcessor +from improvisation_lab.presentation.piece_practice import ( + PieceViewTextManager, WebPiecePracticeView) +from improvisation_lab.service import PiecePracticeService + + +class WebPiecePracticeApp(BasePracticeApp): + """Web application class for piece practice.""" + + def __init__(self, service: PiecePracticeService, config: Config): + """Initialize the application using web UI. + + Args: + service: PiecePracticeService instance. + config: Config instance. + """ + super().__init__(service, config) + + self.audio_processor = WebAudioProcessor( + sample_rate=config.audio.sample_rate, + callback=self._process_audio_callback, + buffer_duration=config.audio.buffer_duration, + ) + + self.text_manager = PieceViewTextManager() + self.ui = WebPiecePracticeView( + on_generate_melody=self.start, + on_end_practice=self.stop, + on_audio_input=self.handle_audio, + song_name=config.piece_practice.selected_song, + ) + + def _process_audio_callback(self, audio_data: np.ndarray): + """Process incoming audio data and update the application state. + + Args: + audio_data: Audio data to process. + """ + if not self.is_running or not self.phrases: + return + + current_phrase = self.phrases[self.current_phrase_idx] + current_note = current_phrase.notes[self.current_note_idx] + + result = self.service.process_audio(audio_data, current_note) + + # Update status display + self.text_manager.update_pitch_result(result) + + # Progress to next note if current note is complete + if result.remaining_time <= 0: + self._advance_to_next_note() + + self.text_manager.update_phrase_text(self.current_phrase_idx, self.phrases) + + def _advance_to_next_note(self): + """Advance to the next note or phrase.""" + if self.phrases is None: + return + self.current_note_idx += 1 + if self.current_note_idx >= len(self.phrases[self.current_phrase_idx].notes): + self.current_note_idx = 0 + self.current_phrase_idx += 1 + if self.current_phrase_idx >= len(self.phrases): + self.current_phrase_idx = 0 + + def handle_audio(self, audio: Tuple[int, np.ndarray]) -> Tuple[str, str]: + """Handle audio input from Gradio interface. + + Args: + audio: Audio data to process. + + Returns: + tuple[str, str]: The current phrase text and result text. + """ + if not self.is_running: + return "Not running", "Start the session first" + + self.audio_processor.process_audio(audio) + return self.text_manager.phrase_text, self.text_manager.result_text + + def start(self) -> tuple[str, str]: + """Start a new practice session. + + Returns: + tuple[str, str]: The current phrase text and result text. + """ + self.phrases = self.service.generate_melody() + self.current_phrase_idx = 0 + self.current_note_idx = 0 + self.is_running = True + + if not self.audio_processor.is_recording: + self.text_manager.initialize_text() + self.audio_processor.start_recording() + + self.text_manager.update_phrase_text(self.current_phrase_idx, self.phrases) + return self.text_manager.phrase_text, self.text_manager.result_text + + def stop(self) -> tuple[str, str]: + """Stop the current practice session. + + Returns: + tuple[str, str]: The current phrase text and result text. + """ + self.is_running = False + if self.audio_processor.is_recording: + self.audio_processor.stop_recording() + self.text_manager.terminate_text() + return self.text_manager.phrase_text, self.text_manager.result_text + + def launch(self, **kwargs): + """Launch the application.""" + self.ui.launch(**kwargs) diff --git a/improvisation_lab/config.py b/improvisation_lab/config.py index 37376c640417824d5754f939005f99fb363aade2..6f1398472c4fcb5632b9e22a714d059fb593b127 100644 --- a/improvisation_lab/config.py +++ b/improvisation_lab/config.py @@ -48,13 +48,47 @@ class AudioConfig: return config +@dataclass +class IntervalPracticeConfig: + """Configuration settings for interval practice.""" + + num_problems: int = 10 + interval: int = 1 + + @classmethod + def from_yaml(cls, yaml_data: dict) -> "IntervalPracticeConfig": + """Create IntervalPracticeConfig instance from YAML data.""" + return cls( + num_problems=yaml_data.get("num_problems", cls.num_problems), + interval=yaml_data.get("interval", cls.interval), + ) + + +@dataclass +class PiecePracticeConfig: + """Configuration settings for piece practice.""" + + selected_song: str = "fly_me_to_the_moon" + chord_progressions: dict = field(default_factory=dict) + + @classmethod + def from_yaml(cls, yaml_data: dict) -> "PiecePracticeConfig": + """Create PiecePracticeConfig instance from YAML data.""" + return cls( + selected_song=yaml_data.get("selected_song", cls.selected_song), + chord_progressions=yaml_data.get( + "chord_progressions", {cls.selected_song: []} + ), + ) + + @dataclass class Config: """Application configuration handler.""" audio: AudioConfig - selected_song: str - chord_progressions: dict + interval_practice: IntervalPracticeConfig + piece_practice: PiecePracticeConfig def __init__(self, config_path: str | Path = "config.yml"): """Initialize Config instance. @@ -70,14 +104,17 @@ class Config: with open(self.config_path, "r") as f: yaml_data = yaml.safe_load(f) self.audio = AudioConfig.from_yaml(yaml_data.get("audio", {})) - self.selected_song = yaml_data.get( - "selected_song", "fly_me_to_the_moon" + self.interval_practice = IntervalPracticeConfig.from_yaml( + yaml_data.get("interval_practice", {}) + ) + self.piece_practice = PiecePracticeConfig.from_yaml( + yaml_data.get("piece_practice", {}) ) - self.chord_progressions = yaml_data.get("chord_progressions", {}) else: self.audio = AudioConfig() - self.selected_song = "fly_me_to_the_moon" - self.chord_progressions = { + self.interval_practice = IntervalPracticeConfig() + self.piece_practice = PiecePracticeConfig() + self.piece_practice.chord_progressions = { # opening 4 bars of Fly Me to the Moon "fly_me_to_the_moon": [ ("A", "natural_minor", "A", "min7", 8), diff --git a/improvisation_lab/domain/composition/melody_composer.py b/improvisation_lab/domain/composition/melody_composer.py index 8d9f54fb3d77bb1402b3b40def4922a2d62a7c01..4275d3a133dbd9f06b7e694b15217079c69093ce 100644 --- a/improvisation_lab/domain/composition/melody_composer.py +++ b/improvisation_lab/domain/composition/melody_composer.py @@ -3,9 +3,10 @@ from dataclasses import dataclass from typing import List, Optional +from improvisation_lab.domain.composition.note_transposer import NoteTransposer from improvisation_lab.domain.composition.phrase_generator import \ PhraseGenerator -from improvisation_lab.domain.music_theory import ChordTone +from improvisation_lab.domain.music_theory import ChordTone, Notes @dataclass @@ -24,6 +25,7 @@ class MelodyComposer: def __init__(self): """Initialize MelodyPlayer with a melody generator.""" self.phrase_generator = PhraseGenerator() + self.note_transposer = NoteTransposer() def generate_phrases( self, progression: List[tuple[str, str, str, str, int]] @@ -69,3 +71,21 @@ class MelodyComposer: ) return phrases + + def generate_interval_melody( + self, base_notes: List[Notes], interval: int + ) -> List[List[Notes]]: + """Generate a melody based on interval transitions. + + Args: + base_notes: List of base notes to start from. + interval: Interval to move to and back. + + Returns: + List of lists containing the generated melody. + """ + melody = [] + for base_note in base_notes: + target_note = self.note_transposer.transpose_note(base_note, interval) + melody.append([base_note, target_note, base_note]) + return melody diff --git a/improvisation_lab/domain/composition/note_transposer.py b/improvisation_lab/domain/composition/note_transposer.py new file mode 100644 index 0000000000000000000000000000000000000000..da8284e5196d59bb54c867f8d47cb6a8189b21c6 --- /dev/null +++ b/improvisation_lab/domain/composition/note_transposer.py @@ -0,0 +1,17 @@ +"""Note transposer.""" + +from improvisation_lab.domain.music_theory import Notes + + +class NoteTransposer: + """Class responsible for transposing notes.""" + + def __init__(self): + """Initialize NoteTransposer.""" + pass + + def transpose_note(self, note: Notes, interval: int) -> Notes: + """Transpose a note by a given interval.""" + chromatic_scale = Notes.get_chromatic_scale(note.value) + transposed_index = (interval) % len(chromatic_scale) + return Notes(chromatic_scale[transposed_index]) diff --git a/improvisation_lab/domain/music_theory.py b/improvisation_lab/domain/music_theory.py index e9f9de4bf205d8b25526e42504238899fa00c56e..9b0c85a1d0bb11eb05afa1c9bd2f8d704f27c735 100644 --- a/improvisation_lab/domain/music_theory.py +++ b/improvisation_lab/domain/music_theory.py @@ -170,3 +170,26 @@ class ChordTone: chord_pattern = cls.CHORD_TONES[chord_type] chromatic = Notes.get_chromatic_scale(root_note) return [chromatic[interval] for interval in chord_pattern] + + +class Intervals: + """Musical interval representation and operations. + + This class handles interval-related operations + including interval generation and interval calculation. + """ + + INTERVALS_MAP = { + "minor 2nd": 1, + "major 2nd": 2, + "minor 3rd": 3, + "major 3rd": 4, + "perfect 4th": 5, + "diminished 5th": 6, + "perfect 5th": 7, + "minor 6th": 8, + "major 6th": 9, + "minor 7th": 10, + "major 7th": 11, + "perfect 8th": 12, + } diff --git a/improvisation_lab/presentation/console_view.py b/improvisation_lab/presentation/console_view.py new file mode 100644 index 0000000000000000000000000000000000000000..162ad7d3fe04941e49b4c9324fc2c4ff3df99d63 --- /dev/null +++ b/improvisation_lab/presentation/console_view.py @@ -0,0 +1,55 @@ +"""Console-based piece practice view. + +This module provides a console interface for visualizing +and interacting with piece practice sessions. +""" + +from abc import ABC, abstractmethod +from typing import List + +from improvisation_lab.domain.composition import PhraseData +from improvisation_lab.presentation.view_text_manager import ViewTextManager +from improvisation_lab.service.base_practice_service import PitchResult + + +class ConsolePracticeView(ABC): + """Console-based implementation of piece practice.""" + + def __init__(self, text_manager: ViewTextManager): + """Initialize the console view with a text manager and song name. + + Args: + text_manager: Text manager for updating and displaying text. + song_name: Name of the song to be practiced. + """ + self.text_manager = text_manager + + @abstractmethod + def launch(self): + """Run the console interface.""" + pass + + def display_phrase_info(self, phrase_number: int, phrases_data: List[PhraseData]): + """Display phrase information in console. + + Args: + phrase_number: Number of the phrase. + phrases_data: List of phrase data. + """ + self.text_manager.update_phrase_text(phrase_number, phrases_data) + print("\n" + "-" * 50) + print("\n" + self.text_manager.phrase_text + "\n") + + def display_pitch_result(self, pitch_result: PitchResult): + """Display note status in console. + + Args: + pitch_result: The result of the pitch detection. + """ + self.text_manager.update_pitch_result(pitch_result) + print(f"{self.text_manager.result_text:<80}", end="\r", flush=True) + + def display_practice_end(self): + """Display practice end message in console.""" + self.text_manager.terminate_text() + print(self.text_manager.phrase_text) diff --git a/improvisation_lab/presentation/interval_practice/__init__.py b/improvisation_lab/presentation/interval_practice/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7476ce010d58db98d700d294752f82212f779fbe --- /dev/null +++ b/improvisation_lab/presentation/interval_practice/__init__.py @@ -0,0 +1,12 @@ +from improvisation_lab.presentation.interval_practice.console_interval_view import \ + ConsoleIntervalPracticeView +from improvisation_lab.presentation.interval_practice.interval_view_text_manager import \ + IntervalViewTextManager # noqa: E501 +from improvisation_lab.presentation.interval_practice.web_interval_view import \ + WebIntervalPracticeView + +__all__ = [ + "WebIntervalPracticeView", + "ConsoleIntervalPracticeView", + "IntervalViewTextManager", +] diff --git a/improvisation_lab/presentation/interval_practice/console_interval_view.py b/improvisation_lab/presentation/interval_practice/console_interval_view.py new file mode 100644 index 0000000000000000000000000000000000000000..bff8d726958e946e17a85e2f611cd9e85dc1f34f --- /dev/null +++ b/improvisation_lab/presentation/interval_practice/console_interval_view.py @@ -0,0 +1,26 @@ +"""Console-based interval practice view. + +This module provides a console interface for visualizing +and interacting with interval practice sessions. +""" + +from improvisation_lab.presentation.console_view import ConsolePracticeView +from improvisation_lab.presentation.interval_practice.interval_view_text_manager import \ + IntervalViewTextManager # noqa: E501 + + +class ConsoleIntervalPracticeView(ConsolePracticeView): + """Console-based implementation of interval visualization.""" + + def __init__(self, text_manager: IntervalViewTextManager): + """Initialize the console view with a text manager. + + Args: + text_manager: Text manager for updating and displaying text. + """ + super().__init__(text_manager) + + def launch(self): + """Run the console interface.""" + print("Interval Practice: ") + print("Sing each note for 1 second!") diff --git a/improvisation_lab/presentation/interval_practice/interval_view_text_manager.py b/improvisation_lab/presentation/interval_practice/interval_view_text_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..06fe024480f1564c7eb916669131de135af40069 --- /dev/null +++ b/improvisation_lab/presentation/interval_practice/interval_view_text_manager.py @@ -0,0 +1,40 @@ +"""Text management for melody practice. + +This class manages the text displayed +in both the web and console versions of the melody practice. +""" + +from typing import List, Optional + +from improvisation_lab.domain.music_theory import Notes +from improvisation_lab.presentation.view_text_manager import ViewTextManager + + +class IntervalViewTextManager(ViewTextManager): + """Displayed text management for melody practice.""" + + def __init__(self): + """Initialize the text manager.""" + super().__init__() + + def update_phrase_text( + self, current_phrase_idx: int, phrases: Optional[List[List[Notes]]] + ): + """Update the phrase text. + + Args: + current_phrase_idx: The index of the current phrase. + phrases: The list of phrases. + """ + if not phrases: + self.phrase_text = "No phrase data" + return self.phrase_text + + current_phrase = phrases[current_phrase_idx] + self.phrase_text = ( + f"Problem {current_phrase_idx + 1}: \n" f"{' -> '.join(current_phrase)}" + ) + + if current_phrase_idx < len(phrases) - 1: + next_phrase = phrases[current_phrase_idx + 1] + self.phrase_text += f"\nNext Base Note: {next_phrase[0].value}" diff --git a/improvisation_lab/presentation/interval_practice/web_interval_view.py b/improvisation_lab/presentation/interval_practice/web_interval_view.py new file mode 100644 index 0000000000000000000000000000000000000000..8fddf9f1f879aaf568a723eca8b2d26aa674b509 --- /dev/null +++ b/improvisation_lab/presentation/interval_practice/web_interval_view.py @@ -0,0 +1,167 @@ +from typing import Callable, Tuple + +import gradio as gr +import numpy as np + +from improvisation_lab.config import Config +from improvisation_lab.domain.music_theory import Intervals +from improvisation_lab.presentation.web_view import WebPracticeView + + +class WebIntervalPracticeView(WebPracticeView): + """Handles the user interface for the melody practice application.""" + + def __init__( + self, + on_generate_melody: Callable[[str, str, int], Tuple[str, str, str]], + on_end_practice: Callable[[], Tuple[str, str, str]], + on_audio_input: Callable[[int, np.ndarray], Tuple[str, str, str]], + config: Config, + ): + """Initialize the UI with callback functions. + + Args: + on_generate_melody: Function to call when start button is clicked + on_end_practice: Function to call when stop button is clicked + on_audio_input: Function to process audio input + """ + super().__init__(on_generate_melody, on_end_practice, on_audio_input) + self.config = config + self._initialize_interval_settings() + + def _initialize_interval_settings(self): + """Initialize interval settings from the configuration.""" + self.init_num_problems = self.config.interval_practice.num_problems + interval = self.config.interval_practice.interval + self.direction_options = ["Up", "Down"] + self.initial_direction = "Up" if interval >= 0 else "Down" + absolute_interval = abs(interval) + self.initial_interval_key = next( + ( + key + for key, value in Intervals.INTERVALS_MAP.items() + if value == absolute_interval + ), + "minor 2nd", # Default value if no match is found + ) + + def _build_interface(self) -> gr.Blocks: + """Create and configure the Gradio interface. + + Returns: + gr.Blocks: The Gradio interface. + """ + # with gr.Blocks() as app: + with gr.Blocks( + head=""" + + """ + ) as app: + self._add_header() + with gr.Row(): + self.interval_box = gr.Dropdown( + list(Intervals.INTERVALS_MAP.keys()), + label="Interval", + value=self.initial_interval_key, + ) + self.direction_box = gr.Radio( + self.direction_options, + label="Direction", + value=self.initial_direction, + ) + self.number_problems_box = gr.Number( + label="Number of Problems", value=self.init_num_problems + ) + + self.generate_melody_button = gr.Button("Generate Melody") + self.base_note_box = gr.Textbox( + label="Base Note", value="", elem_id="base-note-box", visible=False + ) + with gr.Row(): + self.phrase_info_box = gr.Textbox(label="Problem Information", value="") + self.pitch_result_box = gr.Textbox(label="Pitch Result", value="") + self._add_audio_input() + self.end_practice_button = gr.Button("End Practice") + + self._add_buttons_callbacks() + + # Add Tone.js script + app.load( + fn=None, + inputs=None, + outputs=None, + js=""" + () => { + const synth = new Tone.Synth().toDestination(); + //synth.volume.value = 10; + + let isPlaying = false; + let currentNote = null; + + // check for #base-note-box + setInterval(() => { + const input = document.querySelector('#base-note-box textarea'); + const note = input.value; + + if (!note || note === '-' || note.trim() === '') { + if (isPlaying) { + synth.triggerRelease(); + isPlaying = false; + currentNote = null; + } + return; + } + + if (currentNote !== note) { + if (isPlaying) { + synth.triggerRelease(); + } + currentNote = note; + synth.triggerAttack(currentNote.split('\\n')[0] + '3'); + isPlaying = true; + } + }, 100); + } + """, + ) + + return app + + def _add_header(self): + """Create the header section of the UI.""" + gr.Markdown("# Interval Practice\nSing the designated note!") + + def _add_buttons_callbacks(self): + """Create the control buttons section.""" + # Connect button callbacks + self.generate_melody_button.click( + fn=self.on_generate_melody, + inputs=[self.interval_box, self.direction_box, self.number_problems_box], + outputs=[self.base_note_box, self.phrase_info_box, self.pitch_result_box], + ) + + self.end_practice_button.click( + fn=self.on_end_practice, + outputs=[self.base_note_box, self.phrase_info_box, self.pitch_result_box], + ) + + def _add_audio_input(self): + """Create the audio input section.""" + audio_input = gr.Audio( + label="Audio Input", + sources=["microphone"], + streaming=True, + type="numpy", + show_label=True, + ) + + # Attention: have to specify inputs explicitly, + # otherwise the callback function is not called + audio_input.stream( + fn=self.on_audio_input, + inputs=audio_input, + outputs=[self.base_note_box, self.phrase_info_box, self.pitch_result_box], + show_progress=False, + stream_every=0.1, + ) diff --git a/improvisation_lab/presentation/piece_practice/__init__.py b/improvisation_lab/presentation/piece_practice/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0c4f8d5c6c934a6a9bc830cef4ee69ff9748add0 --- /dev/null +++ b/improvisation_lab/presentation/piece_practice/__init__.py @@ -0,0 +1,14 @@ +"""Presentation layer for melody practice. + +This package contains modules for handling the user interface +and text management for melody practice applications. +""" + +from improvisation_lab.presentation.piece_practice.console_piece_view import \ + ConsolePiecePracticeView +from improvisation_lab.presentation.piece_practice.piece_view_text_manager import \ + PieceViewTextManager +from improvisation_lab.presentation.piece_practice.web_piece_view import \ + WebPiecePracticeView + +__all__ = ["WebPiecePracticeView", "PieceViewTextManager", "ConsolePiecePracticeView"] diff --git a/improvisation_lab/presentation/piece_practice/console_piece_view.py b/improvisation_lab/presentation/piece_practice/console_piece_view.py new file mode 100644 index 0000000000000000000000000000000000000000..6e72f36fd60d10b24d2655a0d594f2f893dd1682 --- /dev/null +++ b/improvisation_lab/presentation/piece_practice/console_piece_view.py @@ -0,0 +1,28 @@ +"""Console-based piece practice view. + +This module provides a console interface for visualizing +and interacting with piece practice sessions. +""" + +from improvisation_lab.presentation.console_view import ConsolePracticeView +from improvisation_lab.presentation.piece_practice.piece_view_text_manager import \ + PieceViewTextManager + + +class ConsolePiecePracticeView(ConsolePracticeView): + """Console-based implementation of piece practice.""" + + def __init__(self, text_manager: PieceViewTextManager, song_name: str): + """Initialize the console view with a text manager and song name. + + Args: + text_manager: Text manager for updating and displaying text. + song_name: Name of the song to be practiced. + """ + super().__init__(text_manager) + self.song_name = song_name + + def launch(self): + """Run the console interface.""" + print("\n" + f"Generating melody for {self.song_name}:") + print("Sing each note for 1 second!") diff --git a/improvisation_lab/presentation/piece_practice/piece_view_text_manager.py b/improvisation_lab/presentation/piece_practice/piece_view_text_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..e3ddb8299220fae1f0df3c005b69b3edb46802ab --- /dev/null +++ b/improvisation_lab/presentation/piece_practice/piece_view_text_manager.py @@ -0,0 +1,44 @@ +"""Text management for melody practice. + +This class manages the text displayed +in both the web and console versions of the melody practice. +""" + +from typing import List, Optional + +from improvisation_lab.domain.composition import PhraseData +from improvisation_lab.presentation.view_text_manager import ViewTextManager + + +class PieceViewTextManager(ViewTextManager): + """Displayed text management for melody practice.""" + + def __init__(self): + """Initialize the text manager.""" + super().__init__() + + def update_phrase_text( + self, current_phrase_idx: int, phrases: Optional[List[PhraseData]] + ): + """Update the phrase text. + + Args: + current_phrase_idx: The index of the current phrase. + phrases: The list of phrases. + """ + if not phrases: + self.phrase_text = "No phrase data" + return self.phrase_text + + current_phrase = phrases[current_phrase_idx] + self.phrase_text = ( + f"Phrase {current_phrase_idx + 1}: " + f"{current_phrase.chord_name}\n" + f"{' -> '.join(current_phrase.notes)}" + ) + + if current_phrase_idx < len(phrases) - 1: + next_phrase = phrases[current_phrase_idx + 1] + self.phrase_text += ( + f"\nNext: {next_phrase.chord_name} ({next_phrase.notes[0]})" + ) diff --git a/improvisation_lab/presentation/piece_practice/web_piece_view.py b/improvisation_lab/presentation/piece_practice/web_piece_view.py new file mode 100644 index 0000000000000000000000000000000000000000..8fdd4a6cbcfef9136ef9a96782f3a3ba6bfb10b6 --- /dev/null +++ b/improvisation_lab/presentation/piece_practice/web_piece_view.py @@ -0,0 +1,90 @@ +"""Web-based piece practice view. + +This module provides a web interface using Gradio for visualizing +and interacting with piece practice sessions. +""" + +from typing import Callable, Tuple + +import gradio as gr +import numpy as np + +from improvisation_lab.presentation.web_view import WebPracticeView + + +class WebPiecePracticeView(WebPracticeView): + """Handles the user interface for the piece practice application.""" + + def __init__( + self, + on_generate_melody: Callable[[], Tuple[str, str]], + on_end_practice: Callable[[], Tuple[str, str]], + on_audio_input: Callable[[int, np.ndarray], Tuple[str, str]], + song_name: str, + ): + """Initialize the UI with callback functions. + + Args: + on_generate_melody: Function to call when start button is clicked + on_end_practice: Function to call when stop button is clicked + on_audio_input: Function to process audio input + song_name: Name of the song to be practiced + """ + super().__init__(on_generate_melody, on_end_practice, on_audio_input) + self.song_name = song_name + + def _build_interface(self) -> gr.Blocks: + """Create and configure the Gradio interface. + + Returns: + gr.Blocks: The Gradio interface. + """ + with gr.Blocks() as app: + self._add_header() + self.generate_melody_button = gr.Button("Generate Melody") + with gr.Row(): + self.phrase_info_box = gr.Textbox(label="Phrase Information", value="") + self.pitch_result_box = gr.Textbox(label="Pitch Result", value="") + self._add_audio_input() + self.end_practice_button = gr.Button("End Practice") + + self._add_buttons_callbacks() + + return app + + def _add_header(self): + """Create the header section of the UI.""" + gr.Markdown(f"# {self.song_name} Melody Practice\nSing each note for 1 second!") + + def _add_buttons_callbacks(self): + """Create the control buttons section.""" + # Connect button callbacks + self.generate_melody_button.click( + fn=self.on_generate_melody, + outputs=[self.phrase_info_box, self.pitch_result_box], + ) + + self.end_practice_button.click( + fn=self.on_end_practice, + outputs=[self.phrase_info_box, self.pitch_result_box], + ) + + def _add_audio_input(self): + """Create the audio input section.""" + audio_input = gr.Audio( + label="Audio Input", + sources=["microphone"], + streaming=True, + type="numpy", + show_label=True, + ) + + # Attention: have to specify inputs explicitly, + # otherwise the callback function is not called + audio_input.stream( + fn=self.on_audio_input, + inputs=audio_input, + outputs=[self.phrase_info_box, self.pitch_result_box], + show_progress=False, + stream_every=0.1, + ) diff --git a/improvisation_lab/presentation/view_text_manager.py b/improvisation_lab/presentation/view_text_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..8170cc9a471fbf98e0967e7deb4ef55f2317616c --- /dev/null +++ b/improvisation_lab/presentation/view_text_manager.py @@ -0,0 +1,56 @@ +"""Text management for melody practice. + +This class manages the text displayed +in both the web and console versions of the melody practice. +""" + +from abc import ABC, abstractmethod +from typing import Any, List, Optional + +from improvisation_lab.service.base_practice_service import PitchResult + + +class ViewTextManager(ABC): + """Displayed text management for melody practice.""" + + def __init__(self): + """Initialize the text manager.""" + self.initialize_text() + + def initialize_text(self): + """Initialize the text.""" + self.phrase_text = "No phrase data" + self.result_text = "Ready to start... (waiting for audio)" + + def terminate_text(self): + """Terminate the text.""" + self.phrase_text = "Session Stopped" + self.result_text = "Practice ended" + + def set_waiting_for_audio(self): + """Set the text to waiting for audio.""" + self.result_text = "Waiting for audio..." + + def update_pitch_result(self, pitch_result: PitchResult): + """Update the pitch result text. + + Args: + pitch_result: The result of the pitch detection. + """ + result_text = ( + f"Target: {pitch_result.target_note} | " + f"Your note: {pitch_result.current_base_note or '---'}" + ) + if pitch_result.current_base_note is not None: + result_text += f" | Remaining: {pitch_result.remaining_time:.1f}s" + self.result_text = result_text + + @abstractmethod + def update_phrase_text(self, current_phrase_idx: int, phrases: Optional[List[Any]]): + """Update the phrase text. + + Args: + current_phrase_idx: The index of the current phrase. + phrases: The list of phrases. + """ + pass diff --git a/improvisation_lab/presentation/web_view.py b/improvisation_lab/presentation/web_view.py new file mode 100644 index 0000000000000000000000000000000000000000..afa90abd00a66d2ef218bbebe20c9de94c6b396d --- /dev/null +++ b/improvisation_lab/presentation/web_view.py @@ -0,0 +1,51 @@ +"""Web-based piece practice view. + +This module provides a web interface using Gradio for visualizing +and interacting with piece practice sessions. +""" + +from abc import ABC, abstractmethod +from typing import Any, Callable, Tuple + +import gradio as gr +import numpy as np + + +class WebPracticeView(ABC): + """Handles the user interface for all practice applications.""" + + def __init__( + self, + on_generate_melody: Callable[..., Tuple[Any, ...]], + on_end_practice: Callable[[], Tuple[Any, ...]], + on_audio_input: Callable[[int, np.ndarray], Tuple[Any, ...]], + ): + """Initialize the UI with callback functions. + + Args: + on_generate_melody: Function to call when start button is clicked + on_end_practice: Function to call when stop button is clicked + on_audio_input: Function to process audio input + """ + self.on_generate_melody = on_generate_melody + self.on_end_practice = on_end_practice + self.on_audio_input = on_audio_input + + def launch(self, **kwargs): + """Launch the Gradio application. + + Args: + **kwargs: Additional keyword arguments for the launch method. + """ + app = self._build_interface() + app.queue() + app.launch(**kwargs) + + @abstractmethod + def _build_interface(self) -> gr.Blocks: + """Create and configure the Gradio interface. + + Returns: + gr.Blocks: The Gradio interface. + """ + pass diff --git a/improvisation_lab/service/__init__.py b/improvisation_lab/service/__init__.py index 3d38d2d9f28fd550d769d519c50bac21f28a5169..a99881d04e55d8ed3818186f077113e922996c24 100644 --- a/improvisation_lab/service/__init__.py +++ b/improvisation_lab/service/__init__.py @@ -1,6 +1,8 @@ """Service layer for the Improvisation Lab.""" -from improvisation_lab.service.melody_practice_service import \ - MelodyPracticeService +from improvisation_lab.service.interval_practice_service import \ + IntervalPracticeService +from improvisation_lab.service.piece_practice_service import \ + PiecePracticeService -__all__ = ["MelodyPracticeService"] +__all__ = ["PiecePracticeService", "IntervalPracticeService"] diff --git a/improvisation_lab/service/base_practice_service.py b/improvisation_lab/service/base_practice_service.py new file mode 100644 index 0000000000000000000000000000000000000000..0fbd2c96b4b12678d3ef407447073a9d40e7a765 --- /dev/null +++ b/improvisation_lab/service/base_practice_service.py @@ -0,0 +1,125 @@ +"""Base class for practice services.""" + +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass + +import numpy as np + +from improvisation_lab.config import Config +from improvisation_lab.domain.analysis import PitchDetector +from improvisation_lab.domain.composition import MelodyComposer +from improvisation_lab.domain.music_theory import Notes + + +@dataclass +class PitchResult: + """Result of pitch detection.""" + + target_note: str + current_base_note: str | None + is_correct: bool + remaining_time: float + + +class BasePracticeService(ABC): + """Base class for practice services.""" + + def __init__(self, config: Config): + """Initialize BasePracticeService with configuration.""" + self.config = config + self.melody_composer = MelodyComposer() + self.pitch_detector = PitchDetector(config.audio.pitch_detector) + + self.correct_pitch_start_time: float | None = None + + @abstractmethod + def generate_melody(self, *args, **kwargs): + """Abstract method to generate a melody.""" + pass + + def process_audio(self, audio_data: np.ndarray, target_note: str) -> PitchResult: + """Process audio data to detect pitch and provide feedback. + + Args: + audio_data: Audio data as a numpy array. + target_note: The target note to display. + Returns: + PitchResult containing the target note, detected note, correctness, + and remaining time. + """ + frequency = self.pitch_detector.detect_pitch(audio_data) + + if frequency <= 0: # if no voice detected, reset the correct pitch start time + return self._create_no_voice_result(target_note) + + note_name = Notes.convert_frequency_to_base_note(frequency) + if note_name != target_note: + return self._create_incorrect_pitch_result(target_note, note_name) + + return self._create_correct_pitch_result(target_note, note_name) + + def _create_no_voice_result(self, target_note: str) -> PitchResult: + """Create result for no voice detected case. + + Args: + target_note: The target note to display. + + Returns: + PitchResult for no voice detected case. + """ + self.correct_pitch_start_time = None + return PitchResult( + target_note=target_note, + current_base_note=None, + is_correct=False, + remaining_time=self.config.audio.note_duration, + ) + + def _create_incorrect_pitch_result( + self, target_note: str, detected_note: str + ) -> PitchResult: + """Create result for incorrect pitch case, reset the correct pitch start time. + + Args: + target_note: The target note to display. + detected_note: The detected note. + + Returns: + PitchResult for incorrect pitch case. + """ + self.correct_pitch_start_time = None + return PitchResult( + target_note=target_note, + current_base_note=detected_note, + is_correct=False, + remaining_time=self.config.audio.note_duration, + ) + + def _create_correct_pitch_result( + self, target_note: str, detected_note: str + ) -> PitchResult: + """Create result for correct pitch case. + + Args: + target_note: The target note to display. + detected_note: The detected note. + + Returns: + PitchResult for correct pitch case. + """ + current_time = time.time() + # Note is completed if the correct pitch is sustained for the duration of a note + if self.correct_pitch_start_time is None: + self.correct_pitch_start_time = current_time + remaining_time = self.config.audio.note_duration + else: + elapsed_time = current_time - self.correct_pitch_start_time + remaining_time = max(0, self.config.audio.note_duration - elapsed_time) + + return PitchResult( + target_note=target_note, + current_base_note=detected_note, + is_correct=True, + remaining_time=remaining_time, + ) diff --git a/improvisation_lab/service/interval_practice_service.py b/improvisation_lab/service/interval_practice_service.py new file mode 100644 index 0000000000000000000000000000000000000000..3a986857a59d9177b9af35aab3fc00b8b2bf2bb5 --- /dev/null +++ b/improvisation_lab/service/interval_practice_service.py @@ -0,0 +1,31 @@ +"""Service for interval practice.""" + +from random import sample +from typing import List + +from improvisation_lab.config import Config +from improvisation_lab.domain.music_theory import Notes +from improvisation_lab.service.base_practice_service import BasePracticeService + + +class IntervalPracticeService(BasePracticeService): + """Service for interval practice.""" + + def __init__(self, config: Config): + """Initialize IntervalPracticeService with configuration.""" + super().__init__(config) + + def generate_melody( + self, num_notes: int = 10, interval: int = 1 + ) -> List[List[Notes]]: + """Generate a melody based on interval transitions. + + Args: + num_notes: Number of base notes to generate. Default is 10. + interval: Interval to move to and back. Default is 1 (semitone). + + Returns: + List of Notes objects containing the generated melodic phrases. + """ + base_notes = sample(list(Notes), num_notes) + return self.melody_composer.generate_interval_melody(base_notes, interval) diff --git a/improvisation_lab/service/piece_practice_service.py b/improvisation_lab/service/piece_practice_service.py new file mode 100644 index 0000000000000000000000000000000000000000..28d154e2e154b9ecc352ce153c0e292cbfd95ae8 --- /dev/null +++ b/improvisation_lab/service/piece_practice_service.py @@ -0,0 +1,24 @@ +"""Service for practicing melodies.""" + +from improvisation_lab.config import Config +from improvisation_lab.domain.composition import PhraseData +from improvisation_lab.service.base_practice_service import BasePracticeService + + +class PiecePracticeService(BasePracticeService): + """Service for generating and processing melodies.""" + + def __init__(self, config: Config): + """Initialize PiecePracticeService with configuration.""" + super().__init__(config) + + def generate_melody(self) -> list[PhraseData]: + """Generate a melody based on the configured chord progression. + + Returns: + List of PhraseData instances representing the generated melody. + """ + selected_progression = self.config.piece_practice.chord_progressions[ + self.config.piece_practice.selected_song + ] + return self.melody_composer.generate_phrases(selected_progression) diff --git a/main.py b/main.py index df717bbdd2b3d0140fd082c60f2dcf3a2afc09c1..dbcce2986073ddc7e19643228e202c93f7e49213 100644 --- a/main.py +++ b/main.py @@ -6,10 +6,8 @@ using either a web or console interface. import argparse -from improvisation_lab.application.melody_practice import \ - MelodyPracticeAppFactory +from improvisation_lab.application import PracticeAppFactory from improvisation_lab.config import Config -from improvisation_lab.service import MelodyPracticeService def main(): @@ -21,11 +19,16 @@ def main(): default="web", help="Type of application to run (web or console)", ) + parser.add_argument( + "--practice_type", + choices=["interval", "piece"], + default="interval", + help="Type of practice to run (interval or piece)", + ) args = parser.parse_args() config = Config() - service = MelodyPracticeService(config) - app = MelodyPracticeAppFactory.create_app(args.app_type, service, config) + app = PracticeAppFactory.create_app(args.app_type, args.practice_type, config) app.launch() diff --git a/tests/application/interval_practice/__init__.py b/tests/application/interval_practice/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5469f2035b5e71cb41f4b0a258ac7ae4cfdc0d9c --- /dev/null +++ b/tests/application/interval_practice/__init__.py @@ -0,0 +1 @@ +"""Tests for the interval practice application layer.""" diff --git a/tests/application/interval_practice/test_console_interval_app.py b/tests/application/interval_practice/test_console_interval_app.py new file mode 100644 index 0000000000000000000000000000000000000000..44cafa76602f7cf73cecbffb0d267a6abda516ac --- /dev/null +++ b/tests/application/interval_practice/test_console_interval_app.py @@ -0,0 +1,68 @@ +from unittest.mock import Mock, patch + +import pytest + +from improvisation_lab.application.interval_practice.console_interval_app import \ + ConsoleIntervalPracticeApp +from improvisation_lab.config import Config +from improvisation_lab.domain.music_theory import Notes +from improvisation_lab.infrastructure.audio import DirectAudioProcessor +from improvisation_lab.presentation.interval_practice.console_interval_view import \ + ConsoleIntervalPracticeView +from improvisation_lab.service import IntervalPracticeService + + +class TestConsoleIntervalPracticeApp: + @pytest.fixture + def init_module(self): + """Initialize ConsoleIntervalPracticeApp for testing.""" + config = Config() + service = IntervalPracticeService(config) + self.app = ConsoleIntervalPracticeApp(service, config) + self.app.ui = Mock(spec=ConsoleIntervalPracticeView) + self.app.audio_processor = Mock(spec=DirectAudioProcessor) + self.app.audio_processor.is_recording = False + + @pytest.mark.usefixtures("init_module") + @patch.object(DirectAudioProcessor, "start_recording", return_value=None) + @patch("time.sleep", side_effect=KeyboardInterrupt) + def test_launch(self, mock_start_recording, mock_sleep): + """Test launching the application.""" + self.app.launch() + assert self.app.is_running + assert self.app.current_phrase_idx == 0 + assert self.app.current_note_idx == 0 + self.app.ui.launch.assert_called_once() + self.app.ui.display_phrase_info.assert_called_once_with(0, self.app.phrases) + mock_start_recording.assert_called_once() + + @pytest.mark.usefixtures("init_module") + def test_process_audio_callback(self): + """Test processing audio callback.""" + audio_data = Mock() + self.app.phrases = [ + [Notes.C, Notes.C_SHARP, Notes.C], + [Notes.D, Notes.D_SHARP, Notes.D], + ] + self.app.current_phrase_idx = 0 + self.app.current_note_idx = 2 + + with patch.object( + self.app.service, "process_audio", return_value=Mock(remaining_time=0) + ) as mock_process_audio: + self.app._process_audio_callback(audio_data) + mock_process_audio.assert_called_once_with(audio_data, "C") + self.app.ui.display_pitch_result.assert_called_once() + self.app.ui.display_phrase_info.assert_called_once_with(1, self.app.phrases) + + @pytest.mark.usefixtures("init_module") + def test_advance_to_next_note(self): + """Test advancing to the next note.""" + self.app.phrases = [[Notes.C, Notes.C_SHARP, Notes.C]] + self.app.current_phrase_idx = 0 + self.app.current_note_idx = 2 + + self.app._advance_to_next_note() + assert self.app.current_note_idx == 0 + assert self.app.current_phrase_idx == 0 + self.app.ui.display_phrase_info.assert_called_once_with(0, self.app.phrases) diff --git a/tests/application/interval_practice/test_web_interval_app.py b/tests/application/interval_practice/test_web_interval_app.py new file mode 100644 index 0000000000000000000000000000000000000000..57aa06aa959c5d90531073467ca80044e040c60b --- /dev/null +++ b/tests/application/interval_practice/test_web_interval_app.py @@ -0,0 +1,100 @@ +from unittest.mock import Mock, patch + +import numpy as np +import pytest + +from improvisation_lab.application.interval_practice.web_interval_app import \ + WebIntervalPracticeApp +from improvisation_lab.config import Config +from improvisation_lab.domain.music_theory import Notes +from improvisation_lab.infrastructure.audio import WebAudioProcessor +from improvisation_lab.presentation.interval_practice.web_interval_view import \ + WebIntervalPracticeView +from improvisation_lab.service import IntervalPracticeService + + +class TestWebIntervalPracticeApp: + @pytest.fixture + def init_module(self): + """Initialize WebIntervalPracticeApp for testing.""" + config = Config() + service = IntervalPracticeService(config) + self.app = WebIntervalPracticeApp(service, config) + self.app.ui = Mock(spec=WebIntervalPracticeView) + self.app.audio_processor = Mock(spec=WebAudioProcessor) + + @pytest.mark.usefixtures("init_module") + def test_launch(self): + """Test launching the application.""" + with patch.object(self.app.ui, "launch", return_value=None) as mock_launch: + self.app.launch() + mock_launch.assert_called_once() + + @pytest.mark.usefixtures("init_module") + def test_process_audio_callback(self): + """Test processing audio callback.""" + audio_data = np.array([0.0]) + self.app.is_running = True + self.app.phrases = [ + [Notes.C, Notes.C_SHARP, Notes.C], + [Notes.D, Notes.D_SHARP, Notes.D], + ] + self.app.current_phrase_idx = 0 + self.app.current_note_idx = 1 + + mock_result = Mock() + mock_result.target_note = "C#" + mock_result.current_base_note = "C#" + mock_result.remaining_time = 0.0 + + with patch.object( + self.app.service, "process_audio", return_value=mock_result + ) as mock_process_audio: + self.app._process_audio_callback(audio_data) + mock_process_audio.assert_called_once_with(audio_data, "C#") + assert ( + self.app.text_manager.result_text + == "Target: C# | Your note: C# | Remaining: 0.0s" + ) + + @pytest.mark.usefixtures("init_module") + def test_handle_audio(self): + """Test handling audio input.""" + audio_data = (48000, np.array([0.0])) + self.app.is_running = True + with patch.object( + self.app.audio_processor, "process_audio", return_value=None + ) as mock_process_audio: + base_note, phrase_text, result_text = self.app.handle_audio(audio_data) + mock_process_audio.assert_called_once_with(audio_data) + assert base_note == self.app.base_note + assert phrase_text == self.app.text_manager.phrase_text + assert result_text == self.app.text_manager.result_text + + @pytest.mark.usefixtures("init_module") + def test_start(self): + """Test starting the application.""" + self.app.audio_processor.is_recording = False + with patch.object( + self.app.audio_processor, "start_recording", return_value=None + ) as mock_start_recording: + base_note, phrase_text, result_text = self.app.start("minor 2nd", "Up", 10) + mock_start_recording.assert_called_once() + assert self.app.is_running + assert base_note == self.app.base_note + assert phrase_text == self.app.text_manager.phrase_text + assert result_text == self.app.text_manager.result_text + + @pytest.mark.usefixtures("init_module") + def test_stop(self): + """Test stopping the application.""" + self.app.audio_processor.is_recording = True + with patch.object( + self.app.audio_processor, "stop_recording", return_value=None + ) as mock_stop_recording: + base_note, phrase_text, result_text = self.app.stop() + mock_stop_recording.assert_called_once() + assert not self.app.is_running + assert base_note == "-" + assert phrase_text == self.app.text_manager.phrase_text + assert result_text == self.app.text_manager.result_text diff --git a/tests/application/piece_practice/__init__.py b/tests/application/piece_practice/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..30325beb38989560cdcf5a7436148a838b97e0f1 --- /dev/null +++ b/tests/application/piece_practice/__init__.py @@ -0,0 +1 @@ +"""Tests for the piece practice application layer.""" diff --git a/tests/application/piece_practice/test_console_piece_app.py b/tests/application/piece_practice/test_console_piece_app.py new file mode 100644 index 0000000000000000000000000000000000000000..4eeabe0bf1eb731b9ae477c0e30edffb78a2ddf5 --- /dev/null +++ b/tests/application/piece_practice/test_console_piece_app.py @@ -0,0 +1,71 @@ +"""Tests for the ConsoleMelodyPracticeApp class.""" + +from unittest.mock import Mock, patch + +import pytest + +from improvisation_lab.application.piece_practice.console_piece_app import \ + ConsolePiecePracticeApp +from improvisation_lab.config import Config +from improvisation_lab.infrastructure.audio import DirectAudioProcessor +from improvisation_lab.presentation.piece_practice.console_piece_view import \ + ConsolePiecePracticeView +from improvisation_lab.service import PiecePracticeService + + +class TestConsolePiecePracticeApp: + @pytest.fixture + def init_module(self): + """Initialize ConsolePiecePracticeApp for testing.""" + config = Config() + service = PiecePracticeService(config) + self.app = ConsolePiecePracticeApp(service, config) + self.app.ui = Mock(spec=ConsolePiecePracticeView) + self.app.audio_processor = Mock(spec=DirectAudioProcessor) + self.app.audio_processor.is_recording = False + + @pytest.mark.usefixtures("init_module") + @patch.object(DirectAudioProcessor, "start_recording", return_value=None) + @patch("time.sleep", side_effect=KeyboardInterrupt) + def test_launch(self, mock_start_recording, mock_sleep): + """Test launching the application. + + Args: + mock_start_recording: Mock object for start_recording method. + mock_sleep: Mock object for sleep method. + """ + self.app.launch() + assert self.app.is_running + assert self.app.current_phrase_idx == 0 + assert self.app.current_note_idx == 0 + self.app.ui.launch.assert_called_once() + self.app.ui.display_phrase_info.assert_called_once_with(0, self.app.phrases) + mock_start_recording.assert_called_once() + + @pytest.mark.usefixtures("init_module") + def test_process_audio_callback(self): + """Test processing audio callback.""" + audio_data = Mock() + self.app.phrases = [Mock(notes=["C", "E", "G"]), Mock(notes=["C", "E", "G"])] + self.app.current_phrase_idx = 0 + self.app.current_note_idx = 2 + + with patch.object( + self.app.service, "process_audio", return_value=Mock(remaining_time=0) + ) as mock_process_audio: + self.app._process_audio_callback(audio_data) + mock_process_audio.assert_called_once_with(audio_data, "G") + self.app.ui.display_pitch_result.assert_called_once() + self.app.ui.display_phrase_info.assert_called_once_with(1, self.app.phrases) + + @pytest.mark.usefixtures("init_module") + def test_advance_to_next_note(self): + """Test advancing to the next note.""" + self.app.phrases = [Mock(notes=["C", "E", "G"])] + self.app.current_phrase_idx = 0 + self.app.current_note_idx = 2 + + self.app._advance_to_next_note() + assert self.app.current_note_idx == 0 + assert self.app.current_phrase_idx == 0 + self.app.ui.display_phrase_info.assert_called_once_with(0, self.app.phrases) diff --git a/tests/application/piece_practice/test_web_piece_app.py b/tests/application/piece_practice/test_web_piece_app.py new file mode 100644 index 0000000000000000000000000000000000000000..586fe1afaa41eace7893d1e1e87c9d1e4af6e3f4 --- /dev/null +++ b/tests/application/piece_practice/test_web_piece_app.py @@ -0,0 +1,94 @@ +"""Tests for the WebMelodyPracticeApp class.""" + +from unittest.mock import Mock, patch + +import pytest + +from improvisation_lab.application.piece_practice.web_piece_app import \ + WebPiecePracticeApp +from improvisation_lab.config import Config +from improvisation_lab.infrastructure.audio import WebAudioProcessor +from improvisation_lab.presentation.piece_practice.web_piece_view import \ + WebPiecePracticeView +from improvisation_lab.service import PiecePracticeService + + +class TestWebPiecePracticeApp: + @pytest.fixture + def init_module(self): + """Initialize WebPiecePracticeApp for testing.""" + config = Config() + service = PiecePracticeService(config) + self.app = WebPiecePracticeApp(service, config) + self.app.ui = Mock(spec=WebPiecePracticeView) + self.app.audio_processor = Mock(spec=WebAudioProcessor) + + @pytest.mark.usefixtures("init_module") + def test_launch(self): + """Test launching the application.""" + with patch.object(self.app.ui, "launch", return_value=None) as mock_launch: + self.app.launch() + mock_launch.assert_called_once() + + @pytest.mark.usefixtures("init_module") + def test_process_audio_callback(self): + """Test processing audio callback.""" + audio_data = Mock() + self.app.is_running = True + self.app.phrases = [Mock(notes=["C", "E", "G"]), Mock(notes=["C", "E", "G"])] + self.app.current_phrase_idx = 0 + self.app.current_note_idx = 2 + + mock_result = Mock() + mock_result.target_note = "G" + mock_result.current_base_note = "G" + mock_result.remaining_time = 0.0 + + with patch.object( + self.app.service, "process_audio", return_value=mock_result + ) as mock_process_audio: + self.app._process_audio_callback(audio_data) + mock_process_audio.assert_called_once_with(audio_data, "G") + assert ( + self.app.text_manager.result_text + == "Target: G | Your note: G | Remaining: 0.0s" + ) + + @pytest.mark.usefixtures("init_module") + def test_handle_audio(self): + """Test handling audio input.""" + audio_data = (48000, Mock()) + self.app.is_running = True + with patch.object( + self.app.audio_processor, "process_audio", return_value=None + ) as mock_process_audio: + phrase_text, result_text = self.app.handle_audio(audio_data) + mock_process_audio.assert_called_once_with(audio_data) + assert phrase_text == self.app.text_manager.phrase_text + assert result_text == self.app.text_manager.result_text + + @pytest.mark.usefixtures("init_module") + def test_start(self): + """Test starting the application.""" + self.app.audio_processor.is_recording = False + with patch.object( + self.app.audio_processor, "start_recording", return_value=None + ) as mock_start_recording: + phrase_text, result_text = self.app.start() + mock_start_recording.assert_called_once() + assert self.app.is_running + assert phrase_text == self.app.text_manager.phrase_text + assert result_text == self.app.text_manager.result_text + + @pytest.mark.usefixtures("init_module") + def test_stop(self): + """Test stopping the application.""" + self.app.audio_processor.is_recording = True + with patch.object( + self.app.audio_processor, "stop_recording", return_value=None + ) as mock_stop_recording: + phrase_text, result_text = self.app.stop() + mock_stop_recording.assert_called_once() + assert not self.app.is_running + assert phrase_text == self.app.text_manager.phrase_text + assert result_text == self.app.text_manager.result_text diff --git a/tests/application/test_app_factory.py b/tests/application/test_app_factory.py new file mode 100644 index 0000000000000000000000000000000000000000..34fb9ed389d5761c06408bbe969448b3db96532d --- /dev/null +++ b/tests/application/test_app_factory.py @@ -0,0 +1,46 @@ +"""Tests for the MelodyPracticeAppFactory class.""" + +import pytest + +from improvisation_lab.application.app_factory import PracticeAppFactory +from improvisation_lab.application.interval_practice import ( + ConsoleIntervalPracticeApp, WebIntervalPracticeApp) +from improvisation_lab.application.piece_practice import ( + ConsolePiecePracticeApp, WebPiecePracticeApp) +from improvisation_lab.config import Config + + +class TestPracticeAppFactory: + @pytest.fixture + def init_module(self): + self.config = Config() + + @pytest.mark.usefixtures("init_module") + def test_create_web_piece_app(self): + app = PracticeAppFactory.create_app("web", "piece", self.config) + assert isinstance(app, WebPiecePracticeApp) + + @pytest.mark.usefixtures("init_module") + def test_create_console_piece_app(self): + app = PracticeAppFactory.create_app("console", "piece", self.config) + assert isinstance(app, ConsolePiecePracticeApp) + + @pytest.mark.usefixtures("init_module") + def test_create_web_interval_app(self): + app = PracticeAppFactory.create_app("web", "interval", self.config) + assert isinstance(app, WebIntervalPracticeApp) + + @pytest.mark.usefixtures("init_module") + def test_create_console_interval_app(self): + app = PracticeAppFactory.create_app("console", "interval", self.config) + assert isinstance(app, ConsoleIntervalPracticeApp) + + @pytest.mark.usefixtures("init_module") + def test_create_app_invalid_app_type(self): + with pytest.raises(ValueError): + PracticeAppFactory.create_app("invalid", "piece", self.config) + + @pytest.mark.usefixtures("init_module") + def test_create_app_invalid_practice_type(self): + with pytest.raises(ValueError): + PracticeAppFactory.create_app("web", "invalid", self.config) diff --git a/tests/domain/composition/test_melody_composer.py b/tests/domain/composition/test_melody_composer.py index 898a59173a978ed736d3830d4eb128e1d7c0c62c..34e4b16f425190cd35d023e435e7293a40b4dce4 100644 --- a/tests/domain/composition/test_melody_composer.py +++ b/tests/domain/composition/test_melody_composer.py @@ -3,6 +3,7 @@ import pytest from improvisation_lab.domain.composition.melody_composer import MelodyComposer +from improvisation_lab.domain.music_theory import Notes class TestMelodyComposer: @@ -82,3 +83,24 @@ class TestMelodyComposer: ) ) assert first_note in adjacent_notes + + @pytest.mark.usefixtures("init_module") + def test_generate_interval_melody(self): + """Test interval melody generation.""" + base_notes = [Notes.C, Notes.E, Notes.G, Notes.B] + interval = 2 + + melody = self.melody_composer.generate_interval_melody(base_notes, interval) + + # Check the length of the melody + assert len(melody) == len(base_notes) + assert len(melody[0]) == 3 + + # Check the structure of the melody + for i, base_note in enumerate(base_notes): + assert melody[i][0] == base_note + transposed_note = self.melody_composer.note_transposer.transpose_note( + base_note, interval + ) + assert melody[i][1] == transposed_note + assert melody[i][2] == base_note diff --git a/tests/domain/composition/test_note_transposer.py b/tests/domain/composition/test_note_transposer.py new file mode 100644 index 0000000000000000000000000000000000000000..c693762746a32cdc436b2f8432e0b35854d87e26 --- /dev/null +++ b/tests/domain/composition/test_note_transposer.py @@ -0,0 +1,30 @@ +import pytest + +from improvisation_lab.domain.composition.note_transposer import NoteTransposer +from improvisation_lab.domain.music_theory import Notes + + +class TestNoteTransposer: + @pytest.fixture + def init_transposer(self): + """Initialize NoteTransposer instance for testing.""" + self.transposer = NoteTransposer() + + @pytest.mark.usefixtures("init_transposer") + def test_calculate_target_note(self): + """Test calculation of target note based on interval.""" + # Test cases for different intervals + test_cases = [ + (Notes.C, 1, Notes.C_SHARP), + (Notes.C, 4, Notes.E), + (Notes.B, 1, Notes.C), + (Notes.E, -1, Notes.D_SHARP), + (Notes.C, -2, Notes.A_SHARP), + (Notes.G, 12, Notes.G), # One octave up + ] + + for base_note, interval, expected_target in test_cases: + target_note = self.transposer.transpose_note(base_note, interval) + assert ( + target_note == expected_target + ), f"Failed for base_note={base_note}, interval={interval}" diff --git a/tests/presentation/interval_practice/__init__.py b/tests/presentation/interval_practice/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0f9be9ad45f3c5b5357792279985a6e33ec9b6e3 --- /dev/null +++ b/tests/presentation/interval_practice/__init__.py @@ -0,0 +1 @@ +"""Test Package for Interval Practice Presentation Layer.""" diff --git a/tests/presentation/interval_practice/test_console_interval_view.py b/tests/presentation/interval_practice/test_console_interval_view.py new file mode 100644 index 0000000000000000000000000000000000000000..92970dae1a3a29bda99c1091ba2aa8047bf9ff7f --- /dev/null +++ b/tests/presentation/interval_practice/test_console_interval_view.py @@ -0,0 +1,47 @@ +import pytest + +from improvisation_lab.domain.music_theory import Notes +from improvisation_lab.presentation.interval_practice.console_interval_view import \ + ConsoleIntervalPracticeView +from improvisation_lab.presentation.interval_practice.interval_view_text_manager import \ + IntervalViewTextManager # noqa: E501 +from improvisation_lab.service.base_practice_service import PitchResult + + +class TestConsoleIntervalPracticeView: + """Tests for the ConsoleIntervalPracticeView class.""" + + @pytest.fixture + def init_module(self): + self.text_manager = IntervalViewTextManager() + self.console_view = ConsoleIntervalPracticeView(self.text_manager) + + @pytest.mark.usefixtures("init_module") + def test_launch(self, capsys): + self.console_view.launch() + captured = capsys.readouterr() + assert "Interval Practice:" in captured.out + assert "Sing each note for 1 second!" in captured.out + + @pytest.mark.usefixtures("init_module") + def test_display_phrase_info(self, capsys): + phrases_data = [[Notes.C, Notes.C_SHARP, Notes.C]] + self.console_view.display_phrase_info(0, phrases_data) + captured = capsys.readouterr() + assert "Problem 1:" in captured.out + assert "C -> C# -> C" in captured.out + + @pytest.mark.usefixtures("init_module") + def test_display_pitch_result(self, capsys): + pitch_result = PitchResult( + target_note="C", current_base_note="A", is_correct=False, remaining_time=2.5 + ) + self.console_view.display_pitch_result(pitch_result) + captured = capsys.readouterr() + assert "Target: C | Your note: A | Remaining: 2.5s" in captured.out + + @pytest.mark.usefixtures("init_module") + def test_display_practice_end(self, capsys): + self.console_view.display_practice_end() + captured = capsys.readouterr() + assert "Session Stopped" in captured.out diff --git a/tests/presentation/interval_practice/test_interval_view_text_manager.py b/tests/presentation/interval_practice/test_interval_view_text_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..0b1b895fcc864df8b0b3c055f83bdbaa5fae26d5 --- /dev/null +++ b/tests/presentation/interval_practice/test_interval_view_text_manager.py @@ -0,0 +1,35 @@ +"""Tests for the ViewTextManager class.""" + +import pytest + +from improvisation_lab.domain.music_theory import Notes +from improvisation_lab.presentation.interval_practice.interval_view_text_manager import \ + IntervalViewTextManager # noqa: E501 + + +class TestIntervalViewTextManager: + """Tests for the IntervalViewTextManager class.""" + + @pytest.fixture + def init_module(self): + self.text_manager = IntervalViewTextManager() + + @pytest.mark.usefixtures("init_module") + def test_update_phrase_text_no_phrases(self): + result = self.text_manager.update_phrase_text(0, []) + assert result == "No phrase data" + assert self.text_manager.phrase_text == "No phrase data" + + @pytest.mark.usefixtures("init_module") + def test_update_phrase_text_with_phrases(self): + phrases = [ + [Notes.C, Notes.C_SHARP, Notes.C], + [Notes.A, Notes.A_SHARP, Notes.A], + ] + self.text_manager.update_phrase_text(0, phrases) + expected_text = "Problem 1: \nC -> C# -> C\nNext Base Note: A" + assert self.text_manager.phrase_text == expected_text + + self.text_manager.update_phrase_text(1, phrases) + expected_text = "Problem 2: \nA -> A# -> A" + assert self.text_manager.phrase_text == expected_text diff --git a/tests/presentation/interval_practice/test_web_interval_view.py b/tests/presentation/interval_practice/test_web_interval_view.py new file mode 100644 index 0000000000000000000000000000000000000000..fd83a96d885e35b8d5a4651825e74c41cda78e84 --- /dev/null +++ b/tests/presentation/interval_practice/test_web_interval_view.py @@ -0,0 +1,79 @@ +import warnings +from unittest.mock import Mock, patch + +import gradio as gr +import pytest + +from improvisation_lab.config import Config +from improvisation_lab.presentation.interval_practice.web_interval_view import \ + WebIntervalPracticeView + + +class TestWebIntervalPracticeView: + """Tests for the WebIntervalPracticeView class.""" + + @pytest.fixture + def init_module(self): + self.start_callback = Mock(return_value=("-", "Phrase Info", "Note Status")) + self.stop_callback = Mock( + return_value=("-", "Session Stopped", "Practice ended") + ) + self.audio_callback = Mock( + return_value=("-", "Audio Phrase Info", "Audio Note Status") + ) + config = Config() + + self.web_view = WebIntervalPracticeView( + on_generate_melody=self.start_callback, + on_end_practice=self.stop_callback, + on_audio_input=self.audio_callback, + config=config, + ) + + @pytest.mark.usefixtures("init_module") + def test_initialize_interval_settings(self): + self.web_view._initialize_interval_settings() + assert self.web_view.init_num_problems == 10 + assert self.web_view.initial_direction == "Up" + assert self.web_view.initial_interval_key == "minor 2nd" + + @pytest.mark.usefixtures("init_module") + def test_build_interface(self): + warnings.simplefilter("ignore", category=DeprecationWarning) + app = self.web_view._build_interface() + assert isinstance(app, gr.Blocks) + + @pytest.mark.usefixtures("init_module") + @patch("gradio.Markdown") + def test_create_header(self, mock_markdown): + self.web_view._add_header() + mock_markdown.assert_called_once_with( + "# Interval Practice\nSing the designated note!" + ) + + @pytest.mark.usefixtures("init_module") + def test_create_status_section(self): + self.web_view._build_interface() + assert isinstance(self.web_view.base_note_box, gr.Textbox) + assert isinstance(self.web_view.phrase_info_box, gr.Textbox) + assert isinstance(self.web_view.pitch_result_box, gr.Textbox) + + @pytest.mark.usefixtures("init_module") + def test_create_control_buttons(self): + self.web_view._build_interface() + self.web_view.on_generate_melody() + self.start_callback.assert_called_once() + self.web_view.on_end_practice() + self.stop_callback.assert_called_once() + + @pytest.mark.usefixtures("init_module") + def test_create_audio_input(self): + self.web_view._build_interface() + self.web_view.on_audio_input() + self.audio_callback.assert_called_once() + + @pytest.mark.usefixtures("init_module") + def test_launch(self, mocker): + mocker.patch.object(gr.Blocks, "launch", return_value=None) + self.web_view.launch() + gr.Blocks.launch.assert_called_once() diff --git a/tests/presentation/piece_practice/__init__.py b/tests/presentation/piece_practice/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6e4fdaa56e38cf54792a3eaf04034c400de27ccc --- /dev/null +++ b/tests/presentation/piece_practice/__init__.py @@ -0,0 +1 @@ +"""Test Package for Piece Practice Presentation Layer.""" diff --git a/tests/presentation/piece_practice/test_console_piece_view.py b/tests/presentation/piece_practice/test_console_piece_view.py new file mode 100644 index 0000000000000000000000000000000000000000..8ae9acd08164d813e69e2f01ff98ce38785ff1f7 --- /dev/null +++ b/tests/presentation/piece_practice/test_console_piece_view.py @@ -0,0 +1,54 @@ +import pytest + +from improvisation_lab.domain.composition import PhraseData +from improvisation_lab.presentation.piece_practice.console_piece_view import \ + ConsolePiecePracticeView +from improvisation_lab.presentation.piece_practice.piece_view_text_manager import \ + PieceViewTextManager +from improvisation_lab.service.base_practice_service import PitchResult + + +class TestConsolePiecePracticeView: + """Tests for the ConsolePiecePracticeView class.""" + + @pytest.fixture + def init_module(self): + self.text_manager = PieceViewTextManager() + self.console_view = ConsolePiecePracticeView(self.text_manager, "Test Song") + + @pytest.mark.usefixtures("init_module") + def test_launch(self, capsys): + self.console_view.launch() + captured = capsys.readouterr() + assert "Generating melody for Test Song:" in captured.out + assert "Sing each note for 1 second!" in captured.out + + @pytest.mark.usefixtures("init_module") + def test_display_phrase_info(self, capsys): + phrases_data = [ + PhraseData( + notes=["C", "E", "G"], + chord_name="Cmaj7", + scale_info="C major", + length=4, + ) + ] + self.console_view.display_phrase_info(0, phrases_data) + captured = capsys.readouterr() + assert "Phrase 1: Cmaj7" in captured.out + assert "C -> E -> G" in captured.out + + @pytest.mark.usefixtures("init_module") + def test_display_pitch_result(self, capsys): + pitch_result = PitchResult( + target_note="C", current_base_note="A", is_correct=False, remaining_time=2.5 + ) + self.console_view.display_pitch_result(pitch_result) + captured = capsys.readouterr() + assert "Target: C | Your note: A | Remaining: 2.5s" in captured.out + + @pytest.mark.usefixtures("init_module") + def test_display_practice_end(self, capsys): + self.console_view.display_practice_end() + captured = capsys.readouterr() + assert "Session Stopped" in captured.out diff --git a/tests/presentation/piece_practice/test_piece_view_text_manager.py b/tests/presentation/piece_practice/test_piece_view_text_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..a378b9ebcd09cc1a8f9f0fa79e2c89e944a955b6 --- /dev/null +++ b/tests/presentation/piece_practice/test_piece_view_text_manager.py @@ -0,0 +1,45 @@ +"""Tests for the ViewTextManager class.""" + +import pytest + +from improvisation_lab.domain.composition import PhraseData +from improvisation_lab.presentation.piece_practice.piece_view_text_manager import \ + PieceViewTextManager + + +class TestPieceViewTextManager: + """Tests for the PieceViewTextManager class.""" + + @pytest.fixture + def init_module(self): + self.text_manager = PieceViewTextManager() + + @pytest.mark.usefixtures("init_module") + def test_update_phrase_text_no_phrases(self): + result = self.text_manager.update_phrase_text(0, []) + assert result == "No phrase data" + assert self.text_manager.phrase_text == "No phrase data" + + @pytest.mark.usefixtures("init_module") + def test_update_phrase_text_with_phrases(self): + phrases = [ + PhraseData( + notes=["C", "E", "G"], + chord_name="Cmaj7", + scale_info="C major", + length=4, + ), + PhraseData( + notes=["A", "C", "E"], + chord_name="Amin7", + scale_info="A minor", + length=4, + ), + ] + self.text_manager.update_phrase_text(0, phrases) + expected_text = "Phrase 1: Cmaj7\nC -> E -> G\nNext: Amin7 (A)" + assert self.text_manager.phrase_text == expected_text + + self.text_manager.update_phrase_text(1, phrases) + expected_text = "Phrase 2: Amin7\nA -> C -> E" + assert self.text_manager.phrase_text == expected_text diff --git a/tests/presentation/piece_practice/test_web_piece_view.py b/tests/presentation/piece_practice/test_web_piece_view.py new file mode 100644 index 0000000000000000000000000000000000000000..7ad04ba0fc6e254bb74bf990a4ee0797080cd084 --- /dev/null +++ b/tests/presentation/piece_practice/test_web_piece_view.py @@ -0,0 +1,65 @@ +import warnings +from unittest.mock import Mock, patch + +import gradio as gr +import pytest + +from improvisation_lab.presentation.piece_practice.web_piece_view import \ + WebPiecePracticeView + + +class TestWebPiecePracticeView: + + @pytest.fixture + def init_module(self): + self.start_callback = Mock(return_value=("Phrase Info", "Note Status")) + self.stop_callback = Mock(return_value=("Session Stopped", "Practice ended")) + self.audio_callback = Mock( + return_value=("Audio Phrase Info", "Audio Note Status") + ) + self.web_view = WebPiecePracticeView( + on_generate_melody=self.start_callback, + on_end_practice=self.stop_callback, + on_audio_input=self.audio_callback, + song_name="Test Song", + ) + + @pytest.mark.usefixtures("init_module") + def test_build_interface(self): + warnings.simplefilter("ignore", category=DeprecationWarning) + app = self.web_view._build_interface() + assert isinstance(app, gr.Blocks) + + @pytest.mark.usefixtures("init_module") + @patch("gradio.Markdown") + def test_create_header(self, mock_markdown): + self.web_view._add_header() + mock_markdown.assert_called_once_with( + "# Test Song Melody Practice\nSing each note for 1 second!" + ) + + @pytest.mark.usefixtures("init_module") + def test_create_status_section(self): + self.web_view._build_interface() + assert isinstance(self.web_view.phrase_info_box, gr.Textbox) + assert isinstance(self.web_view.pitch_result_box, gr.Textbox) + + @pytest.mark.usefixtures("init_module") + def test_create_control_buttons(self): + self.web_view._build_interface() + self.web_view.on_generate_melody() + self.start_callback.assert_called_once() + self.web_view.on_end_practice() + self.stop_callback.assert_called_once() + + @pytest.mark.usefixtures("init_module") + def test_create_audio_input(self): + self.web_view._build_interface() + self.web_view.on_audio_input() + self.audio_callback.assert_called_once() + + @pytest.mark.usefixtures("init_module") + def test_launch(self, mocker): + mocker.patch.object(gr.Blocks, "launch", return_value=None) + self.web_view.launch() + gr.Blocks.launch.assert_called_once() diff --git a/tests/presentation/test_view_text_manager.py b/tests/presentation/test_view_text_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..4efa54abe12af283750605eb76ef36a339377dfa --- /dev/null +++ b/tests/presentation/test_view_text_manager.py @@ -0,0 +1,53 @@ +"""Tests for the ViewTextManager class.""" + +from typing import List, Optional + +import pytest + +from improvisation_lab.presentation.view_text_manager import ViewTextManager +from improvisation_lab.service.base_practice_service import PitchResult + + +class MockViewTextManager(ViewTextManager): + """Mock implementation of ViewTextManager for testing.""" + + def __init__(self): + super().__init__() + + def update_phrase_text(self, current_phrase_idx: int, phrases: Optional[List]): + pass + + +class TestViewTextManager: + + @pytest.fixture + def init_module(self): + self.text_manager = MockViewTextManager() + + @pytest.mark.usefixtures("init_module") + def test_initialize_text(self): + self.text_manager.initialize_text() + assert self.text_manager.phrase_text == "No phrase data" + assert self.text_manager.result_text == "Ready to start... (waiting for audio)" + + @pytest.mark.usefixtures("init_module") + def test_terminate_text(self): + self.text_manager.terminate_text() + assert self.text_manager.phrase_text == "Session Stopped" + assert self.text_manager.result_text == "Practice ended" + + @pytest.mark.usefixtures("init_module") + def test_set_waiting_for_audio(self): + self.text_manager.set_waiting_for_audio() + assert self.text_manager.result_text == "Waiting for audio..." + + @pytest.mark.usefixtures("init_module") + def test_update_pitch_result(self): + pitch_result = PitchResult( + target_note="C", current_base_note="A", is_correct=False, remaining_time=2.5 + ) + self.text_manager.update_pitch_result(pitch_result) + assert ( + self.text_manager.result_text + == "Target: C | Your note: A | Remaining: 2.5s" + ) diff --git a/tests/service/test_base_practice_service.py b/tests/service/test_base_practice_service.py new file mode 100644 index 0000000000000000000000000000000000000000..1ae29bcad77c19d174e80cfd8e3d22afd8301bb0 --- /dev/null +++ b/tests/service/test_base_practice_service.py @@ -0,0 +1,106 @@ +"""Tests for BasePracticeService.""" + +import time + +import numpy as np +import pytest + +from improvisation_lab.config import Config +from improvisation_lab.service.base_practice_service import PitchResult +from improvisation_lab.service.piece_practice_service import \ + BasePracticeService + + +class MockBasePracticeService(BasePracticeService): + def generate_melody(self): + pass + + +class TestBasePracticeService: + @pytest.fixture + def init_module(self): + """Create BasePracticeService instance for testing.""" + config = Config() + self.service = MockBasePracticeService(config) + + @pytest.mark.usefixtures("init_module") + def test_process_audio_no_voice(self): + """Test processing audio with no voice detected.""" + audio_data = np.zeros(1024, dtype=np.float32) + result = self.service.process_audio(audio_data, target_note="A") + + assert isinstance(result, PitchResult) + assert result.current_base_note is None + assert not result.is_correct + + @pytest.mark.usefixtures("init_module") + def test_process_audio_with_voice(self): + """Test processing audio with voice detected.""" + sample_rate = 44100 + duration = 0.1 + t = np.linspace(0, duration, int(sample_rate * duration)) + audio_data = np.sin(2 * np.pi * 440 * t) + + result = self.service.process_audio(audio_data, target_note="A") + + assert isinstance(result, PitchResult) + assert result.current_base_note == "A" + assert result.is_correct + + @pytest.mark.usefixtures("init_module") + def test_process_audio_incorrect_pitch(self): + """Test processing audio with incorrect pitch.""" + sample_rate = 44100 + duration = 0.1 + t = np.linspace(0, duration, int(sample_rate * duration)) + # Generate 440Hz (A4) when target is C4 + audio_data = np.sin(2 * np.pi * 440 * t) + + result = self.service.process_audio(audio_data, target_note="C") + + assert isinstance(result, PitchResult) + assert result.current_base_note == "A" + assert not result.is_correct + assert result.remaining_time == self.service.config.audio.note_duration + + @pytest.mark.usefixtures("init_module") + def test_correct_pitch_timing(self): + """Test timing behavior with correct pitch.""" + sample_rate = 44100 + duration = 0.1 + t = np.linspace(0, duration, int(sample_rate * duration)) + audio_data = np.sin(2 * np.pi * 440 * t) + + # First detection + result1 = self.service.process_audio(audio_data, target_note="A") + initial_time = self.service.correct_pitch_start_time + assert result1.is_correct + assert result1.remaining_time == self.service.config.audio.note_duration + + # Wait a bit + time.sleep(0.5) + + # Second detection + result2 = self.service.process_audio(audio_data, target_note="A") + assert result2.is_correct + assert result2.remaining_time < self.service.config.audio.note_duration + assert initial_time == self.service.correct_pitch_start_time + + @pytest.mark.usefixtures("init_module") + def test_correct_pitch_completion(self): + """Test completion of correct pitch duration.""" + sample_rate = 44100 + duration = 0.1 + t = np.linspace(0, duration, int(sample_rate * duration)) + audio_data = np.sin(2 * np.pi * 440 * t) + + # First detection + result1 = self.service.process_audio(audio_data, target_note="A") + assert result1.remaining_time == self.service.config.audio.note_duration + + # Wait for full duration + time.sleep(self.service.config.audio.note_duration + 0.1) + + # Final detection + result2 = self.service.process_audio(audio_data, target_note="A") + assert result2.remaining_time == 0 diff --git a/tests/service/test_interval_practice_service.py b/tests/service/test_interval_practice_service.py new file mode 100644 index 0000000000000000000000000000000000000000..1bf6e31e51b73c19f0394371e7f5a53325e48840 --- /dev/null +++ b/tests/service/test_interval_practice_service.py @@ -0,0 +1,21 @@ +"""Tests for IntervalPracticeService.""" + +from improvisation_lab.config import Config +from improvisation_lab.domain.music_theory import Notes +from improvisation_lab.service.interval_practice_service import \ + IntervalPracticeService + + +class TestPiecePracticeService: + + def test_generate_melody(self): + """Test melody generation.""" + config = Config() + service = IntervalPracticeService(config) + melody = service.generate_melody(num_notes=10, interval=2) + # 10 notes, each with 3 parts (base, transposed, base) + assert len(melody) == 10 + assert all(len(note_group) == 3 for note_group in melody) + assert all( + isinstance(note, Notes) for note_group in melody for note in note_group + ) diff --git a/tests/service/test_piece_practice_service.py b/tests/service/test_piece_practice_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e1196341b5ebcf04c2d74d97077d0c0756b5fc1e --- /dev/null +++ b/tests/service/test_piece_practice_service.py @@ -0,0 +1,19 @@ +"""Tests for PiecePracticeService.""" + +from improvisation_lab.config import Config +from improvisation_lab.service.piece_practice_service import \ + PiecePracticeService + + +class TestPiecePracticeService: + + def test_generate_melody(self): + """Test melody generation.""" + config = Config() + service = PiecePracticeService(config) + phrases = service.generate_melody() + assert len(phrases) > 0 + assert all(hasattr(phrase, "notes") for phrase in phrases) + assert all(hasattr(phrase, "chord_name") for phrase in phrases) + assert all(hasattr(phrase, "scale_info") for phrase in phrases) + assert all(hasattr(phrase, "length") for phrase in phrases) diff --git a/tests/test_config.py b/tests/test_config.py index 066e17ee49e86235d88f87ce25e3c95be8d9a873..f849762ffb1c6f84f990fb263597e9b9a0908eba 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -16,11 +16,17 @@ class TestConfig: "buffer_duration": 0.3, "note_duration": 4, }, - "selected_song": "test_song", - "chord_progressions": { - "test_song": [ - ["C", "major", "C", "maj7", 4], - ] + "interval_practice": { + "num_problems": 15, + "interval": 2, + }, + "piece_practice": { + "selected_song": "test_song", + "chord_progressions": { + "test_song": [ + ["C", "major", "C", "maj7", 4], + ] + }, }, } config_file = tmp_path / "test_config.yml" @@ -35,8 +41,10 @@ class TestConfig: assert config.audio.sample_rate == 48000 assert config.audio.buffer_duration == 0.3 assert config.audio.note_duration == 4 - assert config.selected_song == "test_song" - assert "test_song" in config.chord_progressions + assert config.interval_practice.num_problems == 15 + assert config.interval_practice.interval == 2 + assert config.piece_practice.selected_song == "test_song" + assert "test_song" in config.piece_practice.chord_progressions def test_load_config_with_defaults(self): """Test loading configuration with default values when file doesn't exist.""" @@ -45,8 +53,10 @@ class TestConfig: assert config.audio.sample_rate == 44100 assert config.audio.buffer_duration == 0.2 assert config.audio.note_duration == 1.0 - assert config.selected_song == "fly_me_to_the_moon" - assert "fly_me_to_the_moon" in config.chord_progressions + assert config.interval_practice.num_problems == 10 + assert config.interval_practice.interval == 1 + assert config.piece_practice.selected_song == "fly_me_to_the_moon" + assert "fly_me_to_the_moon" in config.piece_practice.chord_progressions def test_audio_config_from_yaml(self): """Test creating AudioConfig from YAML data."""