atsushieee commited on
Commit
5e84ffc
·
verified ·
1 Parent(s): 76008fd

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. config.yml.example +13 -8
  2. improvisation_lab/application/__init__.py +4 -0
  3. improvisation_lab/application/app_factory.py +43 -0
  4. improvisation_lab/application/base_app.py +51 -0
  5. improvisation_lab/application/base_console_app.py +97 -0
  6. improvisation_lab/application/interval_practice/__init__.py +6 -0
  7. improvisation_lab/application/interval_practice/console_interval_app.py +49 -0
  8. improvisation_lab/application/interval_practice/web_interval_app.py +164 -0
  9. improvisation_lab/application/piece_practice/__init__.py +8 -0
  10. improvisation_lab/application/piece_practice/console_piece_app.py +38 -0
  11. improvisation_lab/application/piece_practice/web_piece_app.py +122 -0
  12. improvisation_lab/config.py +44 -7
  13. improvisation_lab/domain/composition/melody_composer.py +21 -1
  14. improvisation_lab/domain/composition/note_transposer.py +17 -0
  15. improvisation_lab/domain/music_theory.py +23 -0
  16. improvisation_lab/presentation/console_view.py +55 -0
  17. improvisation_lab/presentation/interval_practice/__init__.py +12 -0
  18. improvisation_lab/presentation/interval_practice/console_interval_view.py +26 -0
  19. improvisation_lab/presentation/interval_practice/interval_view_text_manager.py +40 -0
  20. improvisation_lab/presentation/interval_practice/web_interval_view.py +167 -0
  21. improvisation_lab/presentation/piece_practice/__init__.py +14 -0
  22. improvisation_lab/presentation/piece_practice/console_piece_view.py +28 -0
  23. improvisation_lab/presentation/piece_practice/piece_view_text_manager.py +44 -0
  24. improvisation_lab/presentation/piece_practice/web_piece_view.py +90 -0
  25. improvisation_lab/presentation/view_text_manager.py +56 -0
  26. improvisation_lab/presentation/web_view.py +51 -0
  27. improvisation_lab/service/__init__.py +5 -3
  28. improvisation_lab/service/base_practice_service.py +125 -0
  29. improvisation_lab/service/interval_practice_service.py +31 -0
  30. improvisation_lab/service/piece_practice_service.py +24 -0
  31. main.py +8 -5
  32. tests/application/interval_practice/__init__.py +1 -0
  33. tests/application/interval_practice/test_console_interval_app.py +68 -0
  34. tests/application/interval_practice/test_web_interval_app.py +100 -0
  35. tests/application/piece_practice/__init__.py +1 -0
  36. tests/application/piece_practice/test_console_piece_app.py +71 -0
  37. tests/application/piece_practice/test_web_piece_app.py +94 -0
  38. tests/application/test_app_factory.py +46 -0
  39. tests/domain/composition/test_melody_composer.py +22 -0
  40. tests/domain/composition/test_note_transposer.py +30 -0
  41. tests/presentation/interval_practice/__init__.py +1 -0
  42. tests/presentation/interval_practice/test_console_interval_view.py +47 -0
  43. tests/presentation/interval_practice/test_interval_view_text_manager.py +35 -0
  44. tests/presentation/interval_practice/test_web_interval_view.py +79 -0
  45. tests/presentation/piece_practice/__init__.py +1 -0
  46. tests/presentation/piece_practice/test_console_piece_view.py +54 -0
  47. tests/presentation/piece_practice/test_piece_view_text_manager.py +45 -0
  48. tests/presentation/piece_practice/test_web_piece_view.py +65 -0
  49. tests/presentation/test_view_text_manager.py +53 -0
  50. tests/service/test_base_practice_service.py +106 -0
config.yml.example CHANGED
@@ -9,12 +9,17 @@ audio:
9
  f0_max: 880
10
  device: "cpu"
11
 
12
- selected_song: "fly_me_to_the_moon"
 
 
13
 
14
- chord_progressions:
15
- fly_me_to_the_moon:
16
- - ["A", "natural_minor", "A", "min7", 4]
17
- - ["A", "natural_minor", "D", "min7", 4]
18
- - ["C", "major", "G", "dom7", 4]
19
- - ["C", "major", "C", "maj7", 2]
20
- - ["F", "major", "C", "dom7", 2]
 
 
 
 
9
  f0_max: 880
10
  device: "cpu"
11
 
12
+ interval_practice:
13
+ num_problems: 10
14
+ interval: 1
15
 
16
+ piece_practice:
17
+ selected_song: "fly_me_to_the_moon"
18
+
19
+ chord_progressions:
20
+ fly_me_to_the_moon:
21
+ - ["A", "natural_minor", "A", "min7", 4]
22
+ - ["A", "natural_minor", "D", "min7", 4]
23
+ - ["C", "major", "G", "dom7", 4]
24
+ - ["C", "major", "C", "maj7", 2]
25
+ - ["F", "major", "C", "dom7", 2]
improvisation_lab/application/__init__.py CHANGED
@@ -1 +1,5 @@
1
  """Application layer for the Improvisation Lab."""
 
 
 
 
 
1
  """Application layer for the Improvisation Lab."""
2
+
3
+ from improvisation_lab.application.app_factory import PracticeAppFactory
4
+
5
+ __all__ = ["PracticeAppFactory"]
improvisation_lab/application/app_factory.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Factory class for creating melody practice applications."""
2
+
3
+ from improvisation_lab.application.interval_practice import (
4
+ ConsoleIntervalPracticeApp, WebIntervalPracticeApp)
5
+ from improvisation_lab.application.piece_practice import (
6
+ ConsolePiecePracticeApp, WebPiecePracticeApp)
7
+ from improvisation_lab.config import Config
8
+ from improvisation_lab.service import (IntervalPracticeService,
9
+ PiecePracticeService)
10
+
11
+
12
+ class PracticeAppFactory:
13
+ """Factory class for creating melody practice applications."""
14
+
15
+ @staticmethod
16
+ def create_app(app_type: str, practice_type: str, config: Config):
17
+ """Create a melody practice application.
18
+
19
+ Args:
20
+ app_type: Type of application to create.
21
+ practice_type: Type of practice to create.
22
+ config: Config instance.
23
+ """
24
+ if app_type == "web":
25
+ if practice_type == "piece":
26
+ service = PiecePracticeService(config)
27
+ return WebPiecePracticeApp(service, config)
28
+ elif practice_type == "interval":
29
+ service = IntervalPracticeService(config)
30
+ return WebIntervalPracticeApp(service, config)
31
+ else:
32
+ raise ValueError(f"Unknown practice type: {practice_type}")
33
+ elif app_type == "console":
34
+ if practice_type == "piece":
35
+ service = PiecePracticeService(config)
36
+ return ConsolePiecePracticeApp(service, config)
37
+ elif practice_type == "interval":
38
+ service = IntervalPracticeService(config)
39
+ return ConsoleIntervalPracticeApp(service, config)
40
+ else:
41
+ raise ValueError(f"Unknown practice type: {practice_type}")
42
+ else:
43
+ raise ValueError(f"Unknown app type: {app_type}")
improvisation_lab/application/base_app.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base class for melody practice applications."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import List, Optional
5
+
6
+ import numpy as np
7
+
8
+ from improvisation_lab.config import Config
9
+ from improvisation_lab.domain.composition import PhraseData
10
+ from improvisation_lab.service.base_practice_service import BasePracticeService
11
+
12
+
13
+ class BasePracticeApp(ABC):
14
+ """Base class for melody practice applications."""
15
+
16
+ def __init__(self, service: BasePracticeService, config: Config):
17
+ """Initialize the application.
18
+
19
+ Args:
20
+ service: BasePracticeService instance.
21
+ config: Config instance.
22
+ """
23
+ self.service = service
24
+ self.config = config
25
+ self.phrases: Optional[List[PhraseData]] = None
26
+ self.current_phrase_idx: int = 0
27
+ self.current_note_idx: int = 0
28
+ self.is_running: bool = False
29
+
30
+ @abstractmethod
31
+ def _process_audio_callback(self, audio_data: np.ndarray):
32
+ """Process incoming audio data and update the application state.
33
+
34
+ Args:
35
+ audio_data: Audio data to process.
36
+ """
37
+ pass
38
+
39
+ @abstractmethod
40
+ def _advance_to_next_note(self):
41
+ """Advance to the next note or phrase."""
42
+ pass
43
+
44
+ @abstractmethod
45
+ def launch(self, **kwargs):
46
+ """Launch the application.
47
+
48
+ Args:
49
+ **kwargs: Additional keyword arguments for the launch method.
50
+ """
51
+ pass
improvisation_lab/application/base_console_app.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Console application for all practices."""
2
+
3
+ import time
4
+ from abc import ABC, abstractmethod
5
+ from typing import Optional
6
+
7
+ import numpy as np
8
+
9
+ from improvisation_lab.application.base_app import BasePracticeApp
10
+ from improvisation_lab.config import Config
11
+ from improvisation_lab.infrastructure.audio import DirectAudioProcessor
12
+ from improvisation_lab.presentation.console_view import ConsolePracticeView
13
+ from improvisation_lab.service.base_practice_service import BasePracticeService
14
+
15
+
16
+ class ConsoleBasePracticeApp(BasePracticeApp, ABC):
17
+ """Console application class for all practices."""
18
+
19
+ def __init__(self, service: BasePracticeService, config: Config):
20
+ """Initialize the application using console UI.
21
+
22
+ Args:
23
+ service: PracticeService instance.
24
+ config: Config instance.
25
+ """
26
+ super().__init__(service, config)
27
+
28
+ self.audio_processor = DirectAudioProcessor(
29
+ sample_rate=config.audio.sample_rate,
30
+ callback=self._process_audio_callback,
31
+ buffer_duration=config.audio.buffer_duration,
32
+ )
33
+ self.ui: Optional[ConsolePracticeView] = None
34
+
35
+ def _process_audio_callback(self, audio_data: np.ndarray):
36
+ """Process incoming audio data and update the application state.
37
+
38
+ Args:
39
+ audio_data: Audio data to process.
40
+ """
41
+ if self.phrases is None:
42
+ return
43
+ current_note = self._get_current_note()
44
+
45
+ result = self.service.process_audio(audio_data, current_note)
46
+ if self.ui is not None:
47
+ self.ui.display_pitch_result(result)
48
+
49
+ # Progress to next note if current note is complete
50
+ if result.remaining_time <= 0:
51
+ self._advance_to_next_note()
52
+
53
+ def _advance_to_next_note(self):
54
+ """Advance to the next note or phrase."""
55
+ if self.phrases is None:
56
+ return
57
+ self.current_note_idx += 1
58
+ if self.current_note_idx >= len(self._get_current_phrase()):
59
+ self.current_note_idx = 0
60
+ self.current_phrase_idx += 1
61
+ if self.current_phrase_idx >= len(self.phrases):
62
+ self.current_phrase_idx = 0
63
+ self.ui.display_phrase_info(self.current_phrase_idx, self.phrases)
64
+
65
+ def launch(self):
66
+ """Launch the application."""
67
+ self.ui.launch()
68
+ self.phrases = self._generate_melody()
69
+ self.current_phrase_idx = 0
70
+ self.current_note_idx = 0
71
+ self.is_running = True
72
+
73
+ if not self.audio_processor.is_recording:
74
+ try:
75
+ self.audio_processor.start_recording()
76
+ self.ui.display_phrase_info(self.current_phrase_idx, self.phrases)
77
+ while True:
78
+ time.sleep(0.1)
79
+ except KeyboardInterrupt:
80
+ print("\nStopping...")
81
+ finally:
82
+ self.audio_processor.stop_recording()
83
+
84
+ @abstractmethod
85
+ def _get_current_note(self):
86
+ """Return the current note to be processed."""
87
+ pass
88
+
89
+ @abstractmethod
90
+ def _get_current_phrase(self):
91
+ """Return the current phrase to be processed."""
92
+ pass
93
+
94
+ @abstractmethod
95
+ def _generate_melody(self):
96
+ """Generate melody specific to the practice type."""
97
+ pass
improvisation_lab/application/interval_practice/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from improvisation_lab.application.interval_practice.console_interval_app import \
2
+ ConsoleIntervalPracticeApp
3
+ from improvisation_lab.application.interval_practice.web_interval_app import \
4
+ WebIntervalPracticeApp
5
+
6
+ __all__ = ["WebIntervalPracticeApp", "ConsoleIntervalPracticeApp"]
improvisation_lab/application/interval_practice/console_interval_app.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Console application for interval practice."""
2
+
3
+ from typing import List
4
+
5
+ from improvisation_lab.application.base_console_app import \
6
+ ConsoleBasePracticeApp
7
+ from improvisation_lab.config import Config
8
+ from improvisation_lab.domain.music_theory import Notes
9
+ from improvisation_lab.presentation.interval_practice import (
10
+ ConsoleIntervalPracticeView, IntervalViewTextManager)
11
+ from improvisation_lab.service import IntervalPracticeService
12
+
13
+
14
+ class ConsoleIntervalPracticeApp(ConsoleBasePracticeApp):
15
+ """Console application class for interval practice."""
16
+
17
+ def __init__(self, service: IntervalPracticeService, config: Config):
18
+ """Initialize the application using console UI.
19
+
20
+ Args:
21
+ service: IntervalPracticeService instance.
22
+ config: Config instance.
23
+ """
24
+ super().__init__(service, config)
25
+ self.text_manager = IntervalViewTextManager()
26
+ self.ui = ConsoleIntervalPracticeView(self.text_manager)
27
+
28
+ def _get_current_note(self) -> str:
29
+ """Return the current note to be processed.
30
+
31
+ Returns:
32
+ The current note to be processed.
33
+ """
34
+ return self.phrases[self.current_phrase_idx][self.current_note_idx].value
35
+
36
+ def _get_current_phrase(self) -> List[Notes]:
37
+ """Return the current phrase to be processed."""
38
+ return self.phrases[self.current_phrase_idx]
39
+
40
+ def _generate_melody(self) -> List[List[Notes]]:
41
+ """Generate melody specific to the practice type.
42
+
43
+ Returns:
44
+ The generated melody.
45
+ """
46
+ return self.service.generate_melody(
47
+ num_notes=self.config.interval_practice.num_problems,
48
+ interval=self.config.interval_practice.interval,
49
+ )
improvisation_lab/application/interval_practice/web_interval_app.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Web application for interval practice."""
2
+
3
+ from typing import Tuple
4
+
5
+ import numpy as np
6
+
7
+ from improvisation_lab.application.base_app import BasePracticeApp
8
+ from improvisation_lab.config import Config
9
+ from improvisation_lab.domain.music_theory import Intervals
10
+ from improvisation_lab.infrastructure.audio import WebAudioProcessor
11
+ from improvisation_lab.presentation.interval_practice import (
12
+ IntervalViewTextManager, WebIntervalPracticeView)
13
+ from improvisation_lab.service import IntervalPracticeService
14
+
15
+
16
+ class WebIntervalPracticeApp(BasePracticeApp):
17
+ """Web application class for interval practice."""
18
+
19
+ def __init__(self, service: IntervalPracticeService, config: Config):
20
+ """Initialize the application using web UI.
21
+
22
+ Args:
23
+ service: IntervalPracticeService instance.
24
+ config: Config instance.
25
+ """
26
+ super().__init__(service, config)
27
+
28
+ self.audio_processor = WebAudioProcessor(
29
+ sample_rate=config.audio.sample_rate,
30
+ callback=self._process_audio_callback,
31
+ buffer_duration=config.audio.buffer_duration,
32
+ )
33
+
34
+ self.text_manager = IntervalViewTextManager()
35
+ self.ui = WebIntervalPracticeView(
36
+ on_generate_melody=self.start,
37
+ on_end_practice=self.stop,
38
+ on_audio_input=self.handle_audio,
39
+ config=config,
40
+ )
41
+ self.base_note = "-"
42
+
43
+ def _process_audio_callback(self, audio_data: np.ndarray):
44
+ """Process incoming audio data and update the application state.
45
+
46
+ Args:
47
+ audio_data: Audio data to process.
48
+ """
49
+ if not self.is_running or not self.phrases:
50
+ return
51
+
52
+ current_note = self.phrases[self.current_phrase_idx][
53
+ self.current_note_idx
54
+ ].value
55
+
56
+ result = self.service.process_audio(audio_data, current_note)
57
+
58
+ # Update status display
59
+ self.text_manager.update_pitch_result(result)
60
+
61
+ # Progress to next note if current note is complete
62
+ if result.remaining_time <= 0:
63
+ self._advance_to_next_note()
64
+
65
+ self.text_manager.update_phrase_text(self.current_phrase_idx, self.phrases)
66
+
67
+ def _advance_to_next_note(self):
68
+ """Advance to the next note or phrase."""
69
+ if self.phrases is None:
70
+ return
71
+ self.current_note_idx += 1
72
+ if self.current_note_idx >= len(self.phrases[self.current_phrase_idx]):
73
+ self.current_note_idx = 0
74
+ self.current_phrase_idx += 1
75
+ if self.current_phrase_idx >= len(self.phrases):
76
+ self.current_phrase_idx = 0
77
+ self.base_note = self.phrases[self.current_phrase_idx][
78
+ self.current_note_idx
79
+ ].value
80
+
81
+ def handle_audio(self, audio: Tuple[int, np.ndarray]) -> Tuple[str, str, str]:
82
+ """Handle audio input from Gradio interface.
83
+
84
+ Args:
85
+ audio: Audio data to process.
86
+
87
+ Returns:
88
+ Tuple[str, str, str]:
89
+ The current base note including the next base note,
90
+ target note, and result text.
91
+ """
92
+ if not self.is_running:
93
+ return "-", "Not running", "Start the session first"
94
+
95
+ self.audio_processor.process_audio(audio)
96
+ return (
97
+ self.base_note,
98
+ self.text_manager.phrase_text,
99
+ self.text_manager.result_text,
100
+ )
101
+
102
+ def start(
103
+ self, interval: str, direction: str, number_problems: int
104
+ ) -> Tuple[str, str, str]:
105
+ """Start a new practice session.
106
+
107
+ Args:
108
+ interval: Interval to move to and back.
109
+ direction: Direction to move to and back.
110
+ number_problems: Number of problems to generate.
111
+
112
+ Returns:
113
+ Tuple[str, str, str]:
114
+ The current base note including the next base note,
115
+ target note, and result text.
116
+ """
117
+ semitone_interval = Intervals.INTERVALS_MAP.get(interval, 0)
118
+
119
+ if direction == "Down":
120
+ semitone_interval = -semitone_interval
121
+ self.phrases = self.service.generate_melody(
122
+ num_notes=number_problems, interval=semitone_interval
123
+ )
124
+ self.current_phrase_idx = 0
125
+ self.current_note_idx = 0
126
+ self.is_running = True
127
+
128
+ present_note = self.phrases[0][0].value
129
+ self.base_note = present_note
130
+
131
+ if not self.audio_processor.is_recording:
132
+ self.text_manager.initialize_text()
133
+ self.audio_processor.start_recording()
134
+
135
+ self.text_manager.update_phrase_text(self.current_phrase_idx, self.phrases)
136
+
137
+ return (
138
+ self.base_note,
139
+ self.text_manager.phrase_text,
140
+ self.text_manager.result_text,
141
+ )
142
+
143
+ def stop(self) -> Tuple[str, str, str]:
144
+ """Stop the current practice session.
145
+
146
+ Returns:
147
+ tuple[str, str, str]:
148
+ The current base note including the next base note,
149
+ target note, and result text.
150
+ """
151
+ self.is_running = False
152
+ self.base_note = "-"
153
+ if self.audio_processor.is_recording:
154
+ self.audio_processor.stop_recording()
155
+ self.text_manager.terminate_text()
156
+ return (
157
+ self.base_note,
158
+ self.text_manager.phrase_text,
159
+ self.text_manager.result_text,
160
+ )
161
+
162
+ def launch(self, **kwargs):
163
+ """Launch the application."""
164
+ self.ui.launch(**kwargs)
improvisation_lab/application/piece_practice/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """Application layer for piece practice."""
2
+
3
+ from improvisation_lab.application.piece_practice.console_piece_app import \
4
+ ConsolePiecePracticeApp
5
+ from improvisation_lab.application.piece_practice.web_piece_app import \
6
+ WebPiecePracticeApp
7
+
8
+ __all__ = ["ConsolePiecePracticeApp", "WebPiecePracticeApp"]
improvisation_lab/application/piece_practice/console_piece_app.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Console application for piece practice."""
2
+
3
+ from improvisation_lab.application.base_console_app import \
4
+ ConsoleBasePracticeApp
5
+ from improvisation_lab.config import Config
6
+ from improvisation_lab.presentation.piece_practice import (
7
+ ConsolePiecePracticeView, PieceViewTextManager)
8
+ from improvisation_lab.service import PiecePracticeService
9
+
10
+
11
+ class ConsolePiecePracticeApp(ConsoleBasePracticeApp):
12
+ """Console application class for piece practice."""
13
+
14
+ def __init__(self, service: PiecePracticeService, config: Config):
15
+ """Initialize the application using console UI.
16
+
17
+ Args:
18
+ service: PiecePracticeService instance.
19
+ config: Config instance.
20
+ """
21
+ super().__init__(service, config)
22
+ self.text_manager = PieceViewTextManager()
23
+ self.ui = ConsolePiecePracticeView(
24
+ self.text_manager, config.piece_practice.selected_song
25
+ )
26
+
27
+ def _get_current_note(self):
28
+ """Return the current note to be processed."""
29
+ current_phrase = self.phrases[self.current_phrase_idx]
30
+ return current_phrase.notes[self.current_note_idx]
31
+
32
+ def _get_current_phrase(self):
33
+ """Return the current phrase to be processed."""
34
+ return self.phrases[self.current_phrase_idx].notes
35
+
36
+ def _generate_melody(self):
37
+ """Generate melody specific to the practice type."""
38
+ return self.service.generate_melody()
improvisation_lab/application/piece_practice/web_piece_app.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Web application for melody practice."""
2
+
3
+ from typing import Tuple
4
+
5
+ import numpy as np
6
+
7
+ from improvisation_lab.application.base_app import BasePracticeApp
8
+ from improvisation_lab.config import Config
9
+ from improvisation_lab.infrastructure.audio import WebAudioProcessor
10
+ from improvisation_lab.presentation.piece_practice import (
11
+ PieceViewTextManager, WebPiecePracticeView)
12
+ from improvisation_lab.service import PiecePracticeService
13
+
14
+
15
+ class WebPiecePracticeApp(BasePracticeApp):
16
+ """Web application class for piece practice."""
17
+
18
+ def __init__(self, service: PiecePracticeService, config: Config):
19
+ """Initialize the application using web UI.
20
+
21
+ Args:
22
+ service: PiecePracticeService instance.
23
+ config: Config instance.
24
+ """
25
+ super().__init__(service, config)
26
+
27
+ self.audio_processor = WebAudioProcessor(
28
+ sample_rate=config.audio.sample_rate,
29
+ callback=self._process_audio_callback,
30
+ buffer_duration=config.audio.buffer_duration,
31
+ )
32
+
33
+ self.text_manager = PieceViewTextManager()
34
+ self.ui = WebPiecePracticeView(
35
+ on_generate_melody=self.start,
36
+ on_end_practice=self.stop,
37
+ on_audio_input=self.handle_audio,
38
+ song_name=config.piece_practice.selected_song,
39
+ )
40
+
41
+ def _process_audio_callback(self, audio_data: np.ndarray):
42
+ """Process incoming audio data and update the application state.
43
+
44
+ Args:
45
+ audio_data: Audio data to process.
46
+ """
47
+ if not self.is_running or not self.phrases:
48
+ return
49
+
50
+ current_phrase = self.phrases[self.current_phrase_idx]
51
+ current_note = current_phrase.notes[self.current_note_idx]
52
+
53
+ result = self.service.process_audio(audio_data, current_note)
54
+
55
+ # Update status display
56
+ self.text_manager.update_pitch_result(result)
57
+
58
+ # Progress to next note if current note is complete
59
+ if result.remaining_time <= 0:
60
+ self._advance_to_next_note()
61
+
62
+ self.text_manager.update_phrase_text(self.current_phrase_idx, self.phrases)
63
+
64
+ def _advance_to_next_note(self):
65
+ """Advance to the next note or phrase."""
66
+ if self.phrases is None:
67
+ return
68
+ self.current_note_idx += 1
69
+ if self.current_note_idx >= len(self.phrases[self.current_phrase_idx].notes):
70
+ self.current_note_idx = 0
71
+ self.current_phrase_idx += 1
72
+ if self.current_phrase_idx >= len(self.phrases):
73
+ self.current_phrase_idx = 0
74
+
75
+ def handle_audio(self, audio: Tuple[int, np.ndarray]) -> Tuple[str, str]:
76
+ """Handle audio input from Gradio interface.
77
+
78
+ Args:
79
+ audio: Audio data to process.
80
+
81
+ Returns:
82
+ tuple[str, str]: The current phrase text and result text.
83
+ """
84
+ if not self.is_running:
85
+ return "Not running", "Start the session first"
86
+
87
+ self.audio_processor.process_audio(audio)
88
+ return self.text_manager.phrase_text, self.text_manager.result_text
89
+
90
+ def start(self) -> tuple[str, str]:
91
+ """Start a new practice session.
92
+
93
+ Returns:
94
+ tuple[str, str]: The current phrase text and result text.
95
+ """
96
+ self.phrases = self.service.generate_melody()
97
+ self.current_phrase_idx = 0
98
+ self.current_note_idx = 0
99
+ self.is_running = True
100
+
101
+ if not self.audio_processor.is_recording:
102
+ self.text_manager.initialize_text()
103
+ self.audio_processor.start_recording()
104
+
105
+ self.text_manager.update_phrase_text(self.current_phrase_idx, self.phrases)
106
+ return self.text_manager.phrase_text, self.text_manager.result_text
107
+
108
+ def stop(self) -> tuple[str, str]:
109
+ """Stop the current practice session.
110
+
111
+ Returns:
112
+ tuple[str, str]: The current phrase text and result text.
113
+ """
114
+ self.is_running = False
115
+ if self.audio_processor.is_recording:
116
+ self.audio_processor.stop_recording()
117
+ self.text_manager.terminate_text()
118
+ return self.text_manager.phrase_text, self.text_manager.result_text
119
+
120
+ def launch(self, **kwargs):
121
+ """Launch the application."""
122
+ self.ui.launch(**kwargs)
improvisation_lab/config.py CHANGED
@@ -48,13 +48,47 @@ class AudioConfig:
48
  return config
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  @dataclass
52
  class Config:
53
  """Application configuration handler."""
54
 
55
  audio: AudioConfig
56
- selected_song: str
57
- chord_progressions: dict
58
 
59
  def __init__(self, config_path: str | Path = "config.yml"):
60
  """Initialize Config instance.
@@ -70,14 +104,17 @@ class Config:
70
  with open(self.config_path, "r") as f:
71
  yaml_data = yaml.safe_load(f)
72
  self.audio = AudioConfig.from_yaml(yaml_data.get("audio", {}))
73
- self.selected_song = yaml_data.get(
74
- "selected_song", "fly_me_to_the_moon"
 
 
 
75
  )
76
- self.chord_progressions = yaml_data.get("chord_progressions", {})
77
  else:
78
  self.audio = AudioConfig()
79
- self.selected_song = "fly_me_to_the_moon"
80
- self.chord_progressions = {
 
81
  # opening 4 bars of Fly Me to the Moon
82
  "fly_me_to_the_moon": [
83
  ("A", "natural_minor", "A", "min7", 8),
 
48
  return config
49
 
50
 
51
+ @dataclass
52
+ class IntervalPracticeConfig:
53
+ """Configuration settings for interval practice."""
54
+
55
+ num_problems: int = 10
56
+ interval: int = 1
57
+
58
+ @classmethod
59
+ def from_yaml(cls, yaml_data: dict) -> "IntervalPracticeConfig":
60
+ """Create IntervalPracticeConfig instance from YAML data."""
61
+ return cls(
62
+ num_problems=yaml_data.get("num_problems", cls.num_problems),
63
+ interval=yaml_data.get("interval", cls.interval),
64
+ )
65
+
66
+
67
+ @dataclass
68
+ class PiecePracticeConfig:
69
+ """Configuration settings for piece practice."""
70
+
71
+ selected_song: str = "fly_me_to_the_moon"
72
+ chord_progressions: dict = field(default_factory=dict)
73
+
74
+ @classmethod
75
+ def from_yaml(cls, yaml_data: dict) -> "PiecePracticeConfig":
76
+ """Create PiecePracticeConfig instance from YAML data."""
77
+ return cls(
78
+ selected_song=yaml_data.get("selected_song", cls.selected_song),
79
+ chord_progressions=yaml_data.get(
80
+ "chord_progressions", {cls.selected_song: []}
81
+ ),
82
+ )
83
+
84
+
85
  @dataclass
86
  class Config:
87
  """Application configuration handler."""
88
 
89
  audio: AudioConfig
90
+ interval_practice: IntervalPracticeConfig
91
+ piece_practice: PiecePracticeConfig
92
 
93
  def __init__(self, config_path: str | Path = "config.yml"):
94
  """Initialize Config instance.
 
104
  with open(self.config_path, "r") as f:
105
  yaml_data = yaml.safe_load(f)
106
  self.audio = AudioConfig.from_yaml(yaml_data.get("audio", {}))
107
+ self.interval_practice = IntervalPracticeConfig.from_yaml(
108
+ yaml_data.get("interval_practice", {})
109
+ )
110
+ self.piece_practice = PiecePracticeConfig.from_yaml(
111
+ yaml_data.get("piece_practice", {})
112
  )
 
113
  else:
114
  self.audio = AudioConfig()
115
+ self.interval_practice = IntervalPracticeConfig()
116
+ self.piece_practice = PiecePracticeConfig()
117
+ self.piece_practice.chord_progressions = {
118
  # opening 4 bars of Fly Me to the Moon
119
  "fly_me_to_the_moon": [
120
  ("A", "natural_minor", "A", "min7", 8),
improvisation_lab/domain/composition/melody_composer.py CHANGED
@@ -3,9 +3,10 @@
3
  from dataclasses import dataclass
4
  from typing import List, Optional
5
 
 
6
  from improvisation_lab.domain.composition.phrase_generator import \
7
  PhraseGenerator
8
- from improvisation_lab.domain.music_theory import ChordTone
9
 
10
 
11
  @dataclass
@@ -24,6 +25,7 @@ class MelodyComposer:
24
  def __init__(self):
25
  """Initialize MelodyPlayer with a melody generator."""
26
  self.phrase_generator = PhraseGenerator()
 
27
 
28
  def generate_phrases(
29
  self, progression: List[tuple[str, str, str, str, int]]
@@ -69,3 +71,21 @@ class MelodyComposer:
69
  )
70
 
71
  return phrases
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from dataclasses import dataclass
4
  from typing import List, Optional
5
 
6
+ from improvisation_lab.domain.composition.note_transposer import NoteTransposer
7
  from improvisation_lab.domain.composition.phrase_generator import \
8
  PhraseGenerator
9
+ from improvisation_lab.domain.music_theory import ChordTone, Notes
10
 
11
 
12
  @dataclass
 
25
  def __init__(self):
26
  """Initialize MelodyPlayer with a melody generator."""
27
  self.phrase_generator = PhraseGenerator()
28
+ self.note_transposer = NoteTransposer()
29
 
30
  def generate_phrases(
31
  self, progression: List[tuple[str, str, str, str, int]]
 
71
  )
72
 
73
  return phrases
74
+
75
+ def generate_interval_melody(
76
+ self, base_notes: List[Notes], interval: int
77
+ ) -> List[List[Notes]]:
78
+ """Generate a melody based on interval transitions.
79
+
80
+ Args:
81
+ base_notes: List of base notes to start from.
82
+ interval: Interval to move to and back.
83
+
84
+ Returns:
85
+ List of lists containing the generated melody.
86
+ """
87
+ melody = []
88
+ for base_note in base_notes:
89
+ target_note = self.note_transposer.transpose_note(base_note, interval)
90
+ melody.append([base_note, target_note, base_note])
91
+ return melody
improvisation_lab/domain/composition/note_transposer.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Note transposer."""
2
+
3
+ from improvisation_lab.domain.music_theory import Notes
4
+
5
+
6
+ class NoteTransposer:
7
+ """Class responsible for transposing notes."""
8
+
9
+ def __init__(self):
10
+ """Initialize NoteTransposer."""
11
+ pass
12
+
13
+ def transpose_note(self, note: Notes, interval: int) -> Notes:
14
+ """Transpose a note by a given interval."""
15
+ chromatic_scale = Notes.get_chromatic_scale(note.value)
16
+ transposed_index = (interval) % len(chromatic_scale)
17
+ return Notes(chromatic_scale[transposed_index])
improvisation_lab/domain/music_theory.py CHANGED
@@ -170,3 +170,26 @@ class ChordTone:
170
  chord_pattern = cls.CHORD_TONES[chord_type]
171
  chromatic = Notes.get_chromatic_scale(root_note)
172
  return [chromatic[interval] for interval in chord_pattern]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  chord_pattern = cls.CHORD_TONES[chord_type]
171
  chromatic = Notes.get_chromatic_scale(root_note)
172
  return [chromatic[interval] for interval in chord_pattern]
173
+
174
+
175
+ class Intervals:
176
+ """Musical interval representation and operations.
177
+
178
+ This class handles interval-related operations
179
+ including interval generation and interval calculation.
180
+ """
181
+
182
+ INTERVALS_MAP = {
183
+ "minor 2nd": 1,
184
+ "major 2nd": 2,
185
+ "minor 3rd": 3,
186
+ "major 3rd": 4,
187
+ "perfect 4th": 5,
188
+ "diminished 5th": 6,
189
+ "perfect 5th": 7,
190
+ "minor 6th": 8,
191
+ "major 6th": 9,
192
+ "minor 7th": 10,
193
+ "major 7th": 11,
194
+ "perfect 8th": 12,
195
+ }
improvisation_lab/presentation/console_view.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Console-based piece practice view.
2
+
3
+ This module provides a console interface for visualizing
4
+ and interacting with piece practice sessions.
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+ from typing import List
9
+
10
+ from improvisation_lab.domain.composition import PhraseData
11
+ from improvisation_lab.presentation.view_text_manager import ViewTextManager
12
+ from improvisation_lab.service.base_practice_service import PitchResult
13
+
14
+
15
+ class ConsolePracticeView(ABC):
16
+ """Console-based implementation of piece practice."""
17
+
18
+ def __init__(self, text_manager: ViewTextManager):
19
+ """Initialize the console view with a text manager and song name.
20
+
21
+ Args:
22
+ text_manager: Text manager for updating and displaying text.
23
+ song_name: Name of the song to be practiced.
24
+ """
25
+ self.text_manager = text_manager
26
+
27
+ @abstractmethod
28
+ def launch(self):
29
+ """Run the console interface."""
30
+ pass
31
+
32
+ def display_phrase_info(self, phrase_number: int, phrases_data: List[PhraseData]):
33
+ """Display phrase information in console.
34
+
35
+ Args:
36
+ phrase_number: Number of the phrase.
37
+ phrases_data: List of phrase data.
38
+ """
39
+ self.text_manager.update_phrase_text(phrase_number, phrases_data)
40
+ print("\n" + "-" * 50)
41
+ print("\n" + self.text_manager.phrase_text + "\n")
42
+
43
+ def display_pitch_result(self, pitch_result: PitchResult):
44
+ """Display note status in console.
45
+
46
+ Args:
47
+ pitch_result: The result of the pitch detection.
48
+ """
49
+ self.text_manager.update_pitch_result(pitch_result)
50
+ print(f"{self.text_manager.result_text:<80}", end="\r", flush=True)
51
+
52
+ def display_practice_end(self):
53
+ """Display practice end message in console."""
54
+ self.text_manager.terminate_text()
55
+ print(self.text_manager.phrase_text)
improvisation_lab/presentation/interval_practice/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from improvisation_lab.presentation.interval_practice.console_interval_view import \
2
+ ConsoleIntervalPracticeView
3
+ from improvisation_lab.presentation.interval_practice.interval_view_text_manager import \
4
+ IntervalViewTextManager # noqa: E501
5
+ from improvisation_lab.presentation.interval_practice.web_interval_view import \
6
+ WebIntervalPracticeView
7
+
8
+ __all__ = [
9
+ "WebIntervalPracticeView",
10
+ "ConsoleIntervalPracticeView",
11
+ "IntervalViewTextManager",
12
+ ]
improvisation_lab/presentation/interval_practice/console_interval_view.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Console-based interval practice view.
2
+
3
+ This module provides a console interface for visualizing
4
+ and interacting with interval practice sessions.
5
+ """
6
+
7
+ from improvisation_lab.presentation.console_view import ConsolePracticeView
8
+ from improvisation_lab.presentation.interval_practice.interval_view_text_manager import \
9
+ IntervalViewTextManager # noqa: E501
10
+
11
+
12
+ class ConsoleIntervalPracticeView(ConsolePracticeView):
13
+ """Console-based implementation of interval visualization."""
14
+
15
+ def __init__(self, text_manager: IntervalViewTextManager):
16
+ """Initialize the console view with a text manager.
17
+
18
+ Args:
19
+ text_manager: Text manager for updating and displaying text.
20
+ """
21
+ super().__init__(text_manager)
22
+
23
+ def launch(self):
24
+ """Run the console interface."""
25
+ print("Interval Practice: ")
26
+ print("Sing each note for 1 second!")
improvisation_lab/presentation/interval_practice/interval_view_text_manager.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Text management for melody practice.
2
+
3
+ This class manages the text displayed
4
+ in both the web and console versions of the melody practice.
5
+ """
6
+
7
+ from typing import List, Optional
8
+
9
+ from improvisation_lab.domain.music_theory import Notes
10
+ from improvisation_lab.presentation.view_text_manager import ViewTextManager
11
+
12
+
13
+ class IntervalViewTextManager(ViewTextManager):
14
+ """Displayed text management for melody practice."""
15
+
16
+ def __init__(self):
17
+ """Initialize the text manager."""
18
+ super().__init__()
19
+
20
+ def update_phrase_text(
21
+ self, current_phrase_idx: int, phrases: Optional[List[List[Notes]]]
22
+ ):
23
+ """Update the phrase text.
24
+
25
+ Args:
26
+ current_phrase_idx: The index of the current phrase.
27
+ phrases: The list of phrases.
28
+ """
29
+ if not phrases:
30
+ self.phrase_text = "No phrase data"
31
+ return self.phrase_text
32
+
33
+ current_phrase = phrases[current_phrase_idx]
34
+ self.phrase_text = (
35
+ f"Problem {current_phrase_idx + 1}: \n" f"{' -> '.join(current_phrase)}"
36
+ )
37
+
38
+ if current_phrase_idx < len(phrases) - 1:
39
+ next_phrase = phrases[current_phrase_idx + 1]
40
+ self.phrase_text += f"\nNext Base Note: {next_phrase[0].value}"
improvisation_lab/presentation/interval_practice/web_interval_view.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Callable, Tuple
2
+
3
+ import gradio as gr
4
+ import numpy as np
5
+
6
+ from improvisation_lab.config import Config
7
+ from improvisation_lab.domain.music_theory import Intervals
8
+ from improvisation_lab.presentation.web_view import WebPracticeView
9
+
10
+
11
+ class WebIntervalPracticeView(WebPracticeView):
12
+ """Handles the user interface for the melody practice application."""
13
+
14
+ def __init__(
15
+ self,
16
+ on_generate_melody: Callable[[str, str, int], Tuple[str, str, str]],
17
+ on_end_practice: Callable[[], Tuple[str, str, str]],
18
+ on_audio_input: Callable[[int, np.ndarray], Tuple[str, str, str]],
19
+ config: Config,
20
+ ):
21
+ """Initialize the UI with callback functions.
22
+
23
+ Args:
24
+ on_generate_melody: Function to call when start button is clicked
25
+ on_end_practice: Function to call when stop button is clicked
26
+ on_audio_input: Function to process audio input
27
+ """
28
+ super().__init__(on_generate_melody, on_end_practice, on_audio_input)
29
+ self.config = config
30
+ self._initialize_interval_settings()
31
+
32
+ def _initialize_interval_settings(self):
33
+ """Initialize interval settings from the configuration."""
34
+ self.init_num_problems = self.config.interval_practice.num_problems
35
+ interval = self.config.interval_practice.interval
36
+ self.direction_options = ["Up", "Down"]
37
+ self.initial_direction = "Up" if interval >= 0 else "Down"
38
+ absolute_interval = abs(interval)
39
+ self.initial_interval_key = next(
40
+ (
41
+ key
42
+ for key, value in Intervals.INTERVALS_MAP.items()
43
+ if value == absolute_interval
44
+ ),
45
+ "minor 2nd", # Default value if no match is found
46
+ )
47
+
48
+ def _build_interface(self) -> gr.Blocks:
49
+ """Create and configure the Gradio interface.
50
+
51
+ Returns:
52
+ gr.Blocks: The Gradio interface.
53
+ """
54
+ # with gr.Blocks() as app:
55
+ with gr.Blocks(
56
+ head="""
57
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/build/Tone.js">
58
+ </script>
59
+ """
60
+ ) as app:
61
+ self._add_header()
62
+ with gr.Row():
63
+ self.interval_box = gr.Dropdown(
64
+ list(Intervals.INTERVALS_MAP.keys()),
65
+ label="Interval",
66
+ value=self.initial_interval_key,
67
+ )
68
+ self.direction_box = gr.Radio(
69
+ self.direction_options,
70
+ label="Direction",
71
+ value=self.initial_direction,
72
+ )
73
+ self.number_problems_box = gr.Number(
74
+ label="Number of Problems", value=self.init_num_problems
75
+ )
76
+
77
+ self.generate_melody_button = gr.Button("Generate Melody")
78
+ self.base_note_box = gr.Textbox(
79
+ label="Base Note", value="", elem_id="base-note-box", visible=False
80
+ )
81
+ with gr.Row():
82
+ self.phrase_info_box = gr.Textbox(label="Problem Information", value="")
83
+ self.pitch_result_box = gr.Textbox(label="Pitch Result", value="")
84
+ self._add_audio_input()
85
+ self.end_practice_button = gr.Button("End Practice")
86
+
87
+ self._add_buttons_callbacks()
88
+
89
+ # Add Tone.js script
90
+ app.load(
91
+ fn=None,
92
+ inputs=None,
93
+ outputs=None,
94
+ js="""
95
+ () => {
96
+ const synth = new Tone.Synth().toDestination();
97
+ //synth.volume.value = 10;
98
+
99
+ let isPlaying = false;
100
+ let currentNote = null;
101
+
102
+ // check for #base-note-box
103
+ setInterval(() => {
104
+ const input = document.querySelector('#base-note-box textarea');
105
+ const note = input.value;
106
+
107
+ if (!note || note === '-' || note.trim() === '') {
108
+ if (isPlaying) {
109
+ synth.triggerRelease();
110
+ isPlaying = false;
111
+ currentNote = null;
112
+ }
113
+ return;
114
+ }
115
+
116
+ if (currentNote !== note) {
117
+ if (isPlaying) {
118
+ synth.triggerRelease();
119
+ }
120
+ currentNote = note;
121
+ synth.triggerAttack(currentNote.split('\\n')[0] + '3');
122
+ isPlaying = true;
123
+ }
124
+ }, 100);
125
+ }
126
+ """,
127
+ )
128
+
129
+ return app
130
+
131
+ def _add_header(self):
132
+ """Create the header section of the UI."""
133
+ gr.Markdown("# Interval Practice\nSing the designated note!")
134
+
135
+ def _add_buttons_callbacks(self):
136
+ """Create the control buttons section."""
137
+ # Connect button callbacks
138
+ self.generate_melody_button.click(
139
+ fn=self.on_generate_melody,
140
+ inputs=[self.interval_box, self.direction_box, self.number_problems_box],
141
+ outputs=[self.base_note_box, self.phrase_info_box, self.pitch_result_box],
142
+ )
143
+
144
+ self.end_practice_button.click(
145
+ fn=self.on_end_practice,
146
+ outputs=[self.base_note_box, self.phrase_info_box, self.pitch_result_box],
147
+ )
148
+
149
+ def _add_audio_input(self):
150
+ """Create the audio input section."""
151
+ audio_input = gr.Audio(
152
+ label="Audio Input",
153
+ sources=["microphone"],
154
+ streaming=True,
155
+ type="numpy",
156
+ show_label=True,
157
+ )
158
+
159
+ # Attention: have to specify inputs explicitly,
160
+ # otherwise the callback function is not called
161
+ audio_input.stream(
162
+ fn=self.on_audio_input,
163
+ inputs=audio_input,
164
+ outputs=[self.base_note_box, self.phrase_info_box, self.pitch_result_box],
165
+ show_progress=False,
166
+ stream_every=0.1,
167
+ )
improvisation_lab/presentation/piece_practice/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Presentation layer for melody practice.
2
+
3
+ This package contains modules for handling the user interface
4
+ and text management for melody practice applications.
5
+ """
6
+
7
+ from improvisation_lab.presentation.piece_practice.console_piece_view import \
8
+ ConsolePiecePracticeView
9
+ from improvisation_lab.presentation.piece_practice.piece_view_text_manager import \
10
+ PieceViewTextManager
11
+ from improvisation_lab.presentation.piece_practice.web_piece_view import \
12
+ WebPiecePracticeView
13
+
14
+ __all__ = ["WebPiecePracticeView", "PieceViewTextManager", "ConsolePiecePracticeView"]
improvisation_lab/presentation/piece_practice/console_piece_view.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Console-based piece practice view.
2
+
3
+ This module provides a console interface for visualizing
4
+ and interacting with piece practice sessions.
5
+ """
6
+
7
+ from improvisation_lab.presentation.console_view import ConsolePracticeView
8
+ from improvisation_lab.presentation.piece_practice.piece_view_text_manager import \
9
+ PieceViewTextManager
10
+
11
+
12
+ class ConsolePiecePracticeView(ConsolePracticeView):
13
+ """Console-based implementation of piece practice."""
14
+
15
+ def __init__(self, text_manager: PieceViewTextManager, song_name: str):
16
+ """Initialize the console view with a text manager and song name.
17
+
18
+ Args:
19
+ text_manager: Text manager for updating and displaying text.
20
+ song_name: Name of the song to be practiced.
21
+ """
22
+ super().__init__(text_manager)
23
+ self.song_name = song_name
24
+
25
+ def launch(self):
26
+ """Run the console interface."""
27
+ print("\n" + f"Generating melody for {self.song_name}:")
28
+ print("Sing each note for 1 second!")
improvisation_lab/presentation/piece_practice/piece_view_text_manager.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Text management for melody practice.
2
+
3
+ This class manages the text displayed
4
+ in both the web and console versions of the melody practice.
5
+ """
6
+
7
+ from typing import List, Optional
8
+
9
+ from improvisation_lab.domain.composition import PhraseData
10
+ from improvisation_lab.presentation.view_text_manager import ViewTextManager
11
+
12
+
13
+ class PieceViewTextManager(ViewTextManager):
14
+ """Displayed text management for melody practice."""
15
+
16
+ def __init__(self):
17
+ """Initialize the text manager."""
18
+ super().__init__()
19
+
20
+ def update_phrase_text(
21
+ self, current_phrase_idx: int, phrases: Optional[List[PhraseData]]
22
+ ):
23
+ """Update the phrase text.
24
+
25
+ Args:
26
+ current_phrase_idx: The index of the current phrase.
27
+ phrases: The list of phrases.
28
+ """
29
+ if not phrases:
30
+ self.phrase_text = "No phrase data"
31
+ return self.phrase_text
32
+
33
+ current_phrase = phrases[current_phrase_idx]
34
+ self.phrase_text = (
35
+ f"Phrase {current_phrase_idx + 1}: "
36
+ f"{current_phrase.chord_name}\n"
37
+ f"{' -> '.join(current_phrase.notes)}"
38
+ )
39
+
40
+ if current_phrase_idx < len(phrases) - 1:
41
+ next_phrase = phrases[current_phrase_idx + 1]
42
+ self.phrase_text += (
43
+ f"\nNext: {next_phrase.chord_name} ({next_phrase.notes[0]})"
44
+ )
improvisation_lab/presentation/piece_practice/web_piece_view.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Web-based piece practice view.
2
+
3
+ This module provides a web interface using Gradio for visualizing
4
+ and interacting with piece practice sessions.
5
+ """
6
+
7
+ from typing import Callable, Tuple
8
+
9
+ import gradio as gr
10
+ import numpy as np
11
+
12
+ from improvisation_lab.presentation.web_view import WebPracticeView
13
+
14
+
15
+ class WebPiecePracticeView(WebPracticeView):
16
+ """Handles the user interface for the piece practice application."""
17
+
18
+ def __init__(
19
+ self,
20
+ on_generate_melody: Callable[[], Tuple[str, str]],
21
+ on_end_practice: Callable[[], Tuple[str, str]],
22
+ on_audio_input: Callable[[int, np.ndarray], Tuple[str, str]],
23
+ song_name: str,
24
+ ):
25
+ """Initialize the UI with callback functions.
26
+
27
+ Args:
28
+ on_generate_melody: Function to call when start button is clicked
29
+ on_end_practice: Function to call when stop button is clicked
30
+ on_audio_input: Function to process audio input
31
+ song_name: Name of the song to be practiced
32
+ """
33
+ super().__init__(on_generate_melody, on_end_practice, on_audio_input)
34
+ self.song_name = song_name
35
+
36
+ def _build_interface(self) -> gr.Blocks:
37
+ """Create and configure the Gradio interface.
38
+
39
+ Returns:
40
+ gr.Blocks: The Gradio interface.
41
+ """
42
+ with gr.Blocks() as app:
43
+ self._add_header()
44
+ self.generate_melody_button = gr.Button("Generate Melody")
45
+ with gr.Row():
46
+ self.phrase_info_box = gr.Textbox(label="Phrase Information", value="")
47
+ self.pitch_result_box = gr.Textbox(label="Pitch Result", value="")
48
+ self._add_audio_input()
49
+ self.end_practice_button = gr.Button("End Practice")
50
+
51
+ self._add_buttons_callbacks()
52
+
53
+ return app
54
+
55
+ def _add_header(self):
56
+ """Create the header section of the UI."""
57
+ gr.Markdown(f"# {self.song_name} Melody Practice\nSing each note for 1 second!")
58
+
59
+ def _add_buttons_callbacks(self):
60
+ """Create the control buttons section."""
61
+ # Connect button callbacks
62
+ self.generate_melody_button.click(
63
+ fn=self.on_generate_melody,
64
+ outputs=[self.phrase_info_box, self.pitch_result_box],
65
+ )
66
+
67
+ self.end_practice_button.click(
68
+ fn=self.on_end_practice,
69
+ outputs=[self.phrase_info_box, self.pitch_result_box],
70
+ )
71
+
72
+ def _add_audio_input(self):
73
+ """Create the audio input section."""
74
+ audio_input = gr.Audio(
75
+ label="Audio Input",
76
+ sources=["microphone"],
77
+ streaming=True,
78
+ type="numpy",
79
+ show_label=True,
80
+ )
81
+
82
+ # Attention: have to specify inputs explicitly,
83
+ # otherwise the callback function is not called
84
+ audio_input.stream(
85
+ fn=self.on_audio_input,
86
+ inputs=audio_input,
87
+ outputs=[self.phrase_info_box, self.pitch_result_box],
88
+ show_progress=False,
89
+ stream_every=0.1,
90
+ )
improvisation_lab/presentation/view_text_manager.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Text management for melody practice.
2
+
3
+ This class manages the text displayed
4
+ in both the web and console versions of the melody practice.
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+ from typing import Any, List, Optional
9
+
10
+ from improvisation_lab.service.base_practice_service import PitchResult
11
+
12
+
13
+ class ViewTextManager(ABC):
14
+ """Displayed text management for melody practice."""
15
+
16
+ def __init__(self):
17
+ """Initialize the text manager."""
18
+ self.initialize_text()
19
+
20
+ def initialize_text(self):
21
+ """Initialize the text."""
22
+ self.phrase_text = "No phrase data"
23
+ self.result_text = "Ready to start... (waiting for audio)"
24
+
25
+ def terminate_text(self):
26
+ """Terminate the text."""
27
+ self.phrase_text = "Session Stopped"
28
+ self.result_text = "Practice ended"
29
+
30
+ def set_waiting_for_audio(self):
31
+ """Set the text to waiting for audio."""
32
+ self.result_text = "Waiting for audio..."
33
+
34
+ def update_pitch_result(self, pitch_result: PitchResult):
35
+ """Update the pitch result text.
36
+
37
+ Args:
38
+ pitch_result: The result of the pitch detection.
39
+ """
40
+ result_text = (
41
+ f"Target: {pitch_result.target_note} | "
42
+ f"Your note: {pitch_result.current_base_note or '---'}"
43
+ )
44
+ if pitch_result.current_base_note is not None:
45
+ result_text += f" | Remaining: {pitch_result.remaining_time:.1f}s"
46
+ self.result_text = result_text
47
+
48
+ @abstractmethod
49
+ def update_phrase_text(self, current_phrase_idx: int, phrases: Optional[List[Any]]):
50
+ """Update the phrase text.
51
+
52
+ Args:
53
+ current_phrase_idx: The index of the current phrase.
54
+ phrases: The list of phrases.
55
+ """
56
+ pass
improvisation_lab/presentation/web_view.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Web-based piece practice view.
2
+
3
+ This module provides a web interface using Gradio for visualizing
4
+ and interacting with piece practice sessions.
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+ from typing import Any, Callable, Tuple
9
+
10
+ import gradio as gr
11
+ import numpy as np
12
+
13
+
14
+ class WebPracticeView(ABC):
15
+ """Handles the user interface for all practice applications."""
16
+
17
+ def __init__(
18
+ self,
19
+ on_generate_melody: Callable[..., Tuple[Any, ...]],
20
+ on_end_practice: Callable[[], Tuple[Any, ...]],
21
+ on_audio_input: Callable[[int, np.ndarray], Tuple[Any, ...]],
22
+ ):
23
+ """Initialize the UI with callback functions.
24
+
25
+ Args:
26
+ on_generate_melody: Function to call when start button is clicked
27
+ on_end_practice: Function to call when stop button is clicked
28
+ on_audio_input: Function to process audio input
29
+ """
30
+ self.on_generate_melody = on_generate_melody
31
+ self.on_end_practice = on_end_practice
32
+ self.on_audio_input = on_audio_input
33
+
34
+ def launch(self, **kwargs):
35
+ """Launch the Gradio application.
36
+
37
+ Args:
38
+ **kwargs: Additional keyword arguments for the launch method.
39
+ """
40
+ app = self._build_interface()
41
+ app.queue()
42
+ app.launch(**kwargs)
43
+
44
+ @abstractmethod
45
+ def _build_interface(self) -> gr.Blocks:
46
+ """Create and configure the Gradio interface.
47
+
48
+ Returns:
49
+ gr.Blocks: The Gradio interface.
50
+ """
51
+ pass
improvisation_lab/service/__init__.py CHANGED
@@ -1,6 +1,8 @@
1
  """Service layer for the Improvisation Lab."""
2
 
3
- from improvisation_lab.service.melody_practice_service import \
4
- MelodyPracticeService
 
 
5
 
6
- __all__ = ["MelodyPracticeService"]
 
1
  """Service layer for the Improvisation Lab."""
2
 
3
+ from improvisation_lab.service.interval_practice_service import \
4
+ IntervalPracticeService
5
+ from improvisation_lab.service.piece_practice_service import \
6
+ PiecePracticeService
7
 
8
+ __all__ = ["PiecePracticeService", "IntervalPracticeService"]
improvisation_lab/service/base_practice_service.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base class for practice services."""
2
+
3
+ import time
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+
9
+ from improvisation_lab.config import Config
10
+ from improvisation_lab.domain.analysis import PitchDetector
11
+ from improvisation_lab.domain.composition import MelodyComposer
12
+ from improvisation_lab.domain.music_theory import Notes
13
+
14
+
15
+ @dataclass
16
+ class PitchResult:
17
+ """Result of pitch detection."""
18
+
19
+ target_note: str
20
+ current_base_note: str | None
21
+ is_correct: bool
22
+ remaining_time: float
23
+
24
+
25
+ class BasePracticeService(ABC):
26
+ """Base class for practice services."""
27
+
28
+ def __init__(self, config: Config):
29
+ """Initialize BasePracticeService with configuration."""
30
+ self.config = config
31
+ self.melody_composer = MelodyComposer()
32
+ self.pitch_detector = PitchDetector(config.audio.pitch_detector)
33
+
34
+ self.correct_pitch_start_time: float | None = None
35
+
36
+ @abstractmethod
37
+ def generate_melody(self, *args, **kwargs):
38
+ """Abstract method to generate a melody."""
39
+ pass
40
+
41
+ def process_audio(self, audio_data: np.ndarray, target_note: str) -> PitchResult:
42
+ """Process audio data to detect pitch and provide feedback.
43
+
44
+ Args:
45
+ audio_data: Audio data as a numpy array.
46
+ target_note: The target note to display.
47
+ Returns:
48
+ PitchResult containing the target note, detected note, correctness,
49
+ and remaining time.
50
+ """
51
+ frequency = self.pitch_detector.detect_pitch(audio_data)
52
+
53
+ if frequency <= 0: # if no voice detected, reset the correct pitch start time
54
+ return self._create_no_voice_result(target_note)
55
+
56
+ note_name = Notes.convert_frequency_to_base_note(frequency)
57
+ if note_name != target_note:
58
+ return self._create_incorrect_pitch_result(target_note, note_name)
59
+
60
+ return self._create_correct_pitch_result(target_note, note_name)
61
+
62
+ def _create_no_voice_result(self, target_note: str) -> PitchResult:
63
+ """Create result for no voice detected case.
64
+
65
+ Args:
66
+ target_note: The target note to display.
67
+
68
+ Returns:
69
+ PitchResult for no voice detected case.
70
+ """
71
+ self.correct_pitch_start_time = None
72
+ return PitchResult(
73
+ target_note=target_note,
74
+ current_base_note=None,
75
+ is_correct=False,
76
+ remaining_time=self.config.audio.note_duration,
77
+ )
78
+
79
+ def _create_incorrect_pitch_result(
80
+ self, target_note: str, detected_note: str
81
+ ) -> PitchResult:
82
+ """Create result for incorrect pitch case, reset the correct pitch start time.
83
+
84
+ Args:
85
+ target_note: The target note to display.
86
+ detected_note: The detected note.
87
+
88
+ Returns:
89
+ PitchResult for incorrect pitch case.
90
+ """
91
+ self.correct_pitch_start_time = None
92
+ return PitchResult(
93
+ target_note=target_note,
94
+ current_base_note=detected_note,
95
+ is_correct=False,
96
+ remaining_time=self.config.audio.note_duration,
97
+ )
98
+
99
+ def _create_correct_pitch_result(
100
+ self, target_note: str, detected_note: str
101
+ ) -> PitchResult:
102
+ """Create result for correct pitch case.
103
+
104
+ Args:
105
+ target_note: The target note to display.
106
+ detected_note: The detected note.
107
+
108
+ Returns:
109
+ PitchResult for correct pitch case.
110
+ """
111
+ current_time = time.time()
112
+ # Note is completed if the correct pitch is sustained for the duration of a note
113
+ if self.correct_pitch_start_time is None:
114
+ self.correct_pitch_start_time = current_time
115
+ remaining_time = self.config.audio.note_duration
116
+ else:
117
+ elapsed_time = current_time - self.correct_pitch_start_time
118
+ remaining_time = max(0, self.config.audio.note_duration - elapsed_time)
119
+
120
+ return PitchResult(
121
+ target_note=target_note,
122
+ current_base_note=detected_note,
123
+ is_correct=True,
124
+ remaining_time=remaining_time,
125
+ )
improvisation_lab/service/interval_practice_service.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Service for interval practice."""
2
+
3
+ from random import sample
4
+ from typing import List
5
+
6
+ from improvisation_lab.config import Config
7
+ from improvisation_lab.domain.music_theory import Notes
8
+ from improvisation_lab.service.base_practice_service import BasePracticeService
9
+
10
+
11
+ class IntervalPracticeService(BasePracticeService):
12
+ """Service for interval practice."""
13
+
14
+ def __init__(self, config: Config):
15
+ """Initialize IntervalPracticeService with configuration."""
16
+ super().__init__(config)
17
+
18
+ def generate_melody(
19
+ self, num_notes: int = 10, interval: int = 1
20
+ ) -> List[List[Notes]]:
21
+ """Generate a melody based on interval transitions.
22
+
23
+ Args:
24
+ num_notes: Number of base notes to generate. Default is 10.
25
+ interval: Interval to move to and back. Default is 1 (semitone).
26
+
27
+ Returns:
28
+ List of Notes objects containing the generated melodic phrases.
29
+ """
30
+ base_notes = sample(list(Notes), num_notes)
31
+ return self.melody_composer.generate_interval_melody(base_notes, interval)
improvisation_lab/service/piece_practice_service.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Service for practicing melodies."""
2
+
3
+ from improvisation_lab.config import Config
4
+ from improvisation_lab.domain.composition import PhraseData
5
+ from improvisation_lab.service.base_practice_service import BasePracticeService
6
+
7
+
8
+ class PiecePracticeService(BasePracticeService):
9
+ """Service for generating and processing melodies."""
10
+
11
+ def __init__(self, config: Config):
12
+ """Initialize PiecePracticeService with configuration."""
13
+ super().__init__(config)
14
+
15
+ def generate_melody(self) -> list[PhraseData]:
16
+ """Generate a melody based on the configured chord progression.
17
+
18
+ Returns:
19
+ List of PhraseData instances representing the generated melody.
20
+ """
21
+ selected_progression = self.config.piece_practice.chord_progressions[
22
+ self.config.piece_practice.selected_song
23
+ ]
24
+ return self.melody_composer.generate_phrases(selected_progression)
main.py CHANGED
@@ -6,10 +6,8 @@ using either a web or console interface.
6
 
7
  import argparse
8
 
9
- from improvisation_lab.application.melody_practice import \
10
- MelodyPracticeAppFactory
11
  from improvisation_lab.config import Config
12
- from improvisation_lab.service import MelodyPracticeService
13
 
14
 
15
  def main():
@@ -21,11 +19,16 @@ def main():
21
  default="web",
22
  help="Type of application to run (web or console)",
23
  )
 
 
 
 
 
 
24
  args = parser.parse_args()
25
 
26
  config = Config()
27
- service = MelodyPracticeService(config)
28
- app = MelodyPracticeAppFactory.create_app(args.app_type, service, config)
29
  app.launch()
30
 
31
 
 
6
 
7
  import argparse
8
 
9
+ from improvisation_lab.application import PracticeAppFactory
 
10
  from improvisation_lab.config import Config
 
11
 
12
 
13
  def main():
 
19
  default="web",
20
  help="Type of application to run (web or console)",
21
  )
22
+ parser.add_argument(
23
+ "--practice_type",
24
+ choices=["interval", "piece"],
25
+ default="interval",
26
+ help="Type of practice to run (interval or piece)",
27
+ )
28
  args = parser.parse_args()
29
 
30
  config = Config()
31
+ app = PracticeAppFactory.create_app(args.app_type, args.practice_type, config)
 
32
  app.launch()
33
 
34
 
tests/application/interval_practice/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Tests for the interval practice application layer."""
tests/application/interval_practice/test_console_interval_app.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from unittest.mock import Mock, patch
2
+
3
+ import pytest
4
+
5
+ from improvisation_lab.application.interval_practice.console_interval_app import \
6
+ ConsoleIntervalPracticeApp
7
+ from improvisation_lab.config import Config
8
+ from improvisation_lab.domain.music_theory import Notes
9
+ from improvisation_lab.infrastructure.audio import DirectAudioProcessor
10
+ from improvisation_lab.presentation.interval_practice.console_interval_view import \
11
+ ConsoleIntervalPracticeView
12
+ from improvisation_lab.service import IntervalPracticeService
13
+
14
+
15
+ class TestConsoleIntervalPracticeApp:
16
+ @pytest.fixture
17
+ def init_module(self):
18
+ """Initialize ConsoleIntervalPracticeApp for testing."""
19
+ config = Config()
20
+ service = IntervalPracticeService(config)
21
+ self.app = ConsoleIntervalPracticeApp(service, config)
22
+ self.app.ui = Mock(spec=ConsoleIntervalPracticeView)
23
+ self.app.audio_processor = Mock(spec=DirectAudioProcessor)
24
+ self.app.audio_processor.is_recording = False
25
+
26
+ @pytest.mark.usefixtures("init_module")
27
+ @patch.object(DirectAudioProcessor, "start_recording", return_value=None)
28
+ @patch("time.sleep", side_effect=KeyboardInterrupt)
29
+ def test_launch(self, mock_start_recording, mock_sleep):
30
+ """Test launching the application."""
31
+ self.app.launch()
32
+ assert self.app.is_running
33
+ assert self.app.current_phrase_idx == 0
34
+ assert self.app.current_note_idx == 0
35
+ self.app.ui.launch.assert_called_once()
36
+ self.app.ui.display_phrase_info.assert_called_once_with(0, self.app.phrases)
37
+ mock_start_recording.assert_called_once()
38
+
39
+ @pytest.mark.usefixtures("init_module")
40
+ def test_process_audio_callback(self):
41
+ """Test processing audio callback."""
42
+ audio_data = Mock()
43
+ self.app.phrases = [
44
+ [Notes.C, Notes.C_SHARP, Notes.C],
45
+ [Notes.D, Notes.D_SHARP, Notes.D],
46
+ ]
47
+ self.app.current_phrase_idx = 0
48
+ self.app.current_note_idx = 2
49
+
50
+ with patch.object(
51
+ self.app.service, "process_audio", return_value=Mock(remaining_time=0)
52
+ ) as mock_process_audio:
53
+ self.app._process_audio_callback(audio_data)
54
+ mock_process_audio.assert_called_once_with(audio_data, "C")
55
+ self.app.ui.display_pitch_result.assert_called_once()
56
+ self.app.ui.display_phrase_info.assert_called_once_with(1, self.app.phrases)
57
+
58
+ @pytest.mark.usefixtures("init_module")
59
+ def test_advance_to_next_note(self):
60
+ """Test advancing to the next note."""
61
+ self.app.phrases = [[Notes.C, Notes.C_SHARP, Notes.C]]
62
+ self.app.current_phrase_idx = 0
63
+ self.app.current_note_idx = 2
64
+
65
+ self.app._advance_to_next_note()
66
+ assert self.app.current_note_idx == 0
67
+ assert self.app.current_phrase_idx == 0
68
+ self.app.ui.display_phrase_info.assert_called_once_with(0, self.app.phrases)
tests/application/interval_practice/test_web_interval_app.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from unittest.mock import Mock, patch
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ from improvisation_lab.application.interval_practice.web_interval_app import \
7
+ WebIntervalPracticeApp
8
+ from improvisation_lab.config import Config
9
+ from improvisation_lab.domain.music_theory import Notes
10
+ from improvisation_lab.infrastructure.audio import WebAudioProcessor
11
+ from improvisation_lab.presentation.interval_practice.web_interval_view import \
12
+ WebIntervalPracticeView
13
+ from improvisation_lab.service import IntervalPracticeService
14
+
15
+
16
+ class TestWebIntervalPracticeApp:
17
+ @pytest.fixture
18
+ def init_module(self):
19
+ """Initialize WebIntervalPracticeApp for testing."""
20
+ config = Config()
21
+ service = IntervalPracticeService(config)
22
+ self.app = WebIntervalPracticeApp(service, config)
23
+ self.app.ui = Mock(spec=WebIntervalPracticeView)
24
+ self.app.audio_processor = Mock(spec=WebAudioProcessor)
25
+
26
+ @pytest.mark.usefixtures("init_module")
27
+ def test_launch(self):
28
+ """Test launching the application."""
29
+ with patch.object(self.app.ui, "launch", return_value=None) as mock_launch:
30
+ self.app.launch()
31
+ mock_launch.assert_called_once()
32
+
33
+ @pytest.mark.usefixtures("init_module")
34
+ def test_process_audio_callback(self):
35
+ """Test processing audio callback."""
36
+ audio_data = np.array([0.0])
37
+ self.app.is_running = True
38
+ self.app.phrases = [
39
+ [Notes.C, Notes.C_SHARP, Notes.C],
40
+ [Notes.D, Notes.D_SHARP, Notes.D],
41
+ ]
42
+ self.app.current_phrase_idx = 0
43
+ self.app.current_note_idx = 1
44
+
45
+ mock_result = Mock()
46
+ mock_result.target_note = "C#"
47
+ mock_result.current_base_note = "C#"
48
+ mock_result.remaining_time = 0.0
49
+
50
+ with patch.object(
51
+ self.app.service, "process_audio", return_value=mock_result
52
+ ) as mock_process_audio:
53
+ self.app._process_audio_callback(audio_data)
54
+ mock_process_audio.assert_called_once_with(audio_data, "C#")
55
+ assert (
56
+ self.app.text_manager.result_text
57
+ == "Target: C# | Your note: C# | Remaining: 0.0s"
58
+ )
59
+
60
+ @pytest.mark.usefixtures("init_module")
61
+ def test_handle_audio(self):
62
+ """Test handling audio input."""
63
+ audio_data = (48000, np.array([0.0]))
64
+ self.app.is_running = True
65
+ with patch.object(
66
+ self.app.audio_processor, "process_audio", return_value=None
67
+ ) as mock_process_audio:
68
+ base_note, phrase_text, result_text = self.app.handle_audio(audio_data)
69
+ mock_process_audio.assert_called_once_with(audio_data)
70
+ assert base_note == self.app.base_note
71
+ assert phrase_text == self.app.text_manager.phrase_text
72
+ assert result_text == self.app.text_manager.result_text
73
+
74
+ @pytest.mark.usefixtures("init_module")
75
+ def test_start(self):
76
+ """Test starting the application."""
77
+ self.app.audio_processor.is_recording = False
78
+ with patch.object(
79
+ self.app.audio_processor, "start_recording", return_value=None
80
+ ) as mock_start_recording:
81
+ base_note, phrase_text, result_text = self.app.start("minor 2nd", "Up", 10)
82
+ mock_start_recording.assert_called_once()
83
+ assert self.app.is_running
84
+ assert base_note == self.app.base_note
85
+ assert phrase_text == self.app.text_manager.phrase_text
86
+ assert result_text == self.app.text_manager.result_text
87
+
88
+ @pytest.mark.usefixtures("init_module")
89
+ def test_stop(self):
90
+ """Test stopping the application."""
91
+ self.app.audio_processor.is_recording = True
92
+ with patch.object(
93
+ self.app.audio_processor, "stop_recording", return_value=None
94
+ ) as mock_stop_recording:
95
+ base_note, phrase_text, result_text = self.app.stop()
96
+ mock_stop_recording.assert_called_once()
97
+ assert not self.app.is_running
98
+ assert base_note == "-"
99
+ assert phrase_text == self.app.text_manager.phrase_text
100
+ assert result_text == self.app.text_manager.result_text
tests/application/piece_practice/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Tests for the piece practice application layer."""
tests/application/piece_practice/test_console_piece_app.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the ConsoleMelodyPracticeApp class."""
2
+
3
+ from unittest.mock import Mock, patch
4
+
5
+ import pytest
6
+
7
+ from improvisation_lab.application.piece_practice.console_piece_app import \
8
+ ConsolePiecePracticeApp
9
+ from improvisation_lab.config import Config
10
+ from improvisation_lab.infrastructure.audio import DirectAudioProcessor
11
+ from improvisation_lab.presentation.piece_practice.console_piece_view import \
12
+ ConsolePiecePracticeView
13
+ from improvisation_lab.service import PiecePracticeService
14
+
15
+
16
+ class TestConsolePiecePracticeApp:
17
+ @pytest.fixture
18
+ def init_module(self):
19
+ """Initialize ConsolePiecePracticeApp for testing."""
20
+ config = Config()
21
+ service = PiecePracticeService(config)
22
+ self.app = ConsolePiecePracticeApp(service, config)
23
+ self.app.ui = Mock(spec=ConsolePiecePracticeView)
24
+ self.app.audio_processor = Mock(spec=DirectAudioProcessor)
25
+ self.app.audio_processor.is_recording = False
26
+
27
+ @pytest.mark.usefixtures("init_module")
28
+ @patch.object(DirectAudioProcessor, "start_recording", return_value=None)
29
+ @patch("time.sleep", side_effect=KeyboardInterrupt)
30
+ def test_launch(self, mock_start_recording, mock_sleep):
31
+ """Test launching the application.
32
+
33
+ Args:
34
+ mock_start_recording: Mock object for start_recording method.
35
+ mock_sleep: Mock object for sleep method.
36
+ """
37
+ self.app.launch()
38
+ assert self.app.is_running
39
+ assert self.app.current_phrase_idx == 0
40
+ assert self.app.current_note_idx == 0
41
+ self.app.ui.launch.assert_called_once()
42
+ self.app.ui.display_phrase_info.assert_called_once_with(0, self.app.phrases)
43
+ mock_start_recording.assert_called_once()
44
+
45
+ @pytest.mark.usefixtures("init_module")
46
+ def test_process_audio_callback(self):
47
+ """Test processing audio callback."""
48
+ audio_data = Mock()
49
+ self.app.phrases = [Mock(notes=["C", "E", "G"]), Mock(notes=["C", "E", "G"])]
50
+ self.app.current_phrase_idx = 0
51
+ self.app.current_note_idx = 2
52
+
53
+ with patch.object(
54
+ self.app.service, "process_audio", return_value=Mock(remaining_time=0)
55
+ ) as mock_process_audio:
56
+ self.app._process_audio_callback(audio_data)
57
+ mock_process_audio.assert_called_once_with(audio_data, "G")
58
+ self.app.ui.display_pitch_result.assert_called_once()
59
+ self.app.ui.display_phrase_info.assert_called_once_with(1, self.app.phrases)
60
+
61
+ @pytest.mark.usefixtures("init_module")
62
+ def test_advance_to_next_note(self):
63
+ """Test advancing to the next note."""
64
+ self.app.phrases = [Mock(notes=["C", "E", "G"])]
65
+ self.app.current_phrase_idx = 0
66
+ self.app.current_note_idx = 2
67
+
68
+ self.app._advance_to_next_note()
69
+ assert self.app.current_note_idx == 0
70
+ assert self.app.current_phrase_idx == 0
71
+ self.app.ui.display_phrase_info.assert_called_once_with(0, self.app.phrases)
tests/application/piece_practice/test_web_piece_app.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the WebMelodyPracticeApp class."""
2
+
3
+ from unittest.mock import Mock, patch
4
+
5
+ import pytest
6
+
7
+ from improvisation_lab.application.piece_practice.web_piece_app import \
8
+ WebPiecePracticeApp
9
+ from improvisation_lab.config import Config
10
+ from improvisation_lab.infrastructure.audio import WebAudioProcessor
11
+ from improvisation_lab.presentation.piece_practice.web_piece_view import \
12
+ WebPiecePracticeView
13
+ from improvisation_lab.service import PiecePracticeService
14
+
15
+
16
+ class TestWebPiecePracticeApp:
17
+ @pytest.fixture
18
+ def init_module(self):
19
+ """Initialize WebPiecePracticeApp for testing."""
20
+ config = Config()
21
+ service = PiecePracticeService(config)
22
+ self.app = WebPiecePracticeApp(service, config)
23
+ self.app.ui = Mock(spec=WebPiecePracticeView)
24
+ self.app.audio_processor = Mock(spec=WebAudioProcessor)
25
+
26
+ @pytest.mark.usefixtures("init_module")
27
+ def test_launch(self):
28
+ """Test launching the application."""
29
+ with patch.object(self.app.ui, "launch", return_value=None) as mock_launch:
30
+ self.app.launch()
31
+ mock_launch.assert_called_once()
32
+
33
+ @pytest.mark.usefixtures("init_module")
34
+ def test_process_audio_callback(self):
35
+ """Test processing audio callback."""
36
+ audio_data = Mock()
37
+ self.app.is_running = True
38
+ self.app.phrases = [Mock(notes=["C", "E", "G"]), Mock(notes=["C", "E", "G"])]
39
+ self.app.current_phrase_idx = 0
40
+ self.app.current_note_idx = 2
41
+
42
+ mock_result = Mock()
43
+ mock_result.target_note = "G"
44
+ mock_result.current_base_note = "G"
45
+ mock_result.remaining_time = 0.0
46
+
47
+ with patch.object(
48
+ self.app.service, "process_audio", return_value=mock_result
49
+ ) as mock_process_audio:
50
+ self.app._process_audio_callback(audio_data)
51
+ mock_process_audio.assert_called_once_with(audio_data, "G")
52
+ assert (
53
+ self.app.text_manager.result_text
54
+ == "Target: G | Your note: G | Remaining: 0.0s"
55
+ )
56
+
57
+ @pytest.mark.usefixtures("init_module")
58
+ def test_handle_audio(self):
59
+ """Test handling audio input."""
60
+ audio_data = (48000, Mock())
61
+ self.app.is_running = True
62
+ with patch.object(
63
+ self.app.audio_processor, "process_audio", return_value=None
64
+ ) as mock_process_audio:
65
+ phrase_text, result_text = self.app.handle_audio(audio_data)
66
+ mock_process_audio.assert_called_once_with(audio_data)
67
+ assert phrase_text == self.app.text_manager.phrase_text
68
+ assert result_text == self.app.text_manager.result_text
69
+
70
+ @pytest.mark.usefixtures("init_module")
71
+ def test_start(self):
72
+ """Test starting the application."""
73
+ self.app.audio_processor.is_recording = False
74
+ with patch.object(
75
+ self.app.audio_processor, "start_recording", return_value=None
76
+ ) as mock_start_recording:
77
+ phrase_text, result_text = self.app.start()
78
+ mock_start_recording.assert_called_once()
79
+ assert self.app.is_running
80
+ assert phrase_text == self.app.text_manager.phrase_text
81
+ assert result_text == self.app.text_manager.result_text
82
+
83
+ @pytest.mark.usefixtures("init_module")
84
+ def test_stop(self):
85
+ """Test stopping the application."""
86
+ self.app.audio_processor.is_recording = True
87
+ with patch.object(
88
+ self.app.audio_processor, "stop_recording", return_value=None
89
+ ) as mock_stop_recording:
90
+ phrase_text, result_text = self.app.stop()
91
+ mock_stop_recording.assert_called_once()
92
+ assert not self.app.is_running
93
+ assert phrase_text == self.app.text_manager.phrase_text
94
+ assert result_text == self.app.text_manager.result_text
tests/application/test_app_factory.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the MelodyPracticeAppFactory class."""
2
+
3
+ import pytest
4
+
5
+ from improvisation_lab.application.app_factory import PracticeAppFactory
6
+ from improvisation_lab.application.interval_practice import (
7
+ ConsoleIntervalPracticeApp, WebIntervalPracticeApp)
8
+ from improvisation_lab.application.piece_practice import (
9
+ ConsolePiecePracticeApp, WebPiecePracticeApp)
10
+ from improvisation_lab.config import Config
11
+
12
+
13
+ class TestPracticeAppFactory:
14
+ @pytest.fixture
15
+ def init_module(self):
16
+ self.config = Config()
17
+
18
+ @pytest.mark.usefixtures("init_module")
19
+ def test_create_web_piece_app(self):
20
+ app = PracticeAppFactory.create_app("web", "piece", self.config)
21
+ assert isinstance(app, WebPiecePracticeApp)
22
+
23
+ @pytest.mark.usefixtures("init_module")
24
+ def test_create_console_piece_app(self):
25
+ app = PracticeAppFactory.create_app("console", "piece", self.config)
26
+ assert isinstance(app, ConsolePiecePracticeApp)
27
+
28
+ @pytest.mark.usefixtures("init_module")
29
+ def test_create_web_interval_app(self):
30
+ app = PracticeAppFactory.create_app("web", "interval", self.config)
31
+ assert isinstance(app, WebIntervalPracticeApp)
32
+
33
+ @pytest.mark.usefixtures("init_module")
34
+ def test_create_console_interval_app(self):
35
+ app = PracticeAppFactory.create_app("console", "interval", self.config)
36
+ assert isinstance(app, ConsoleIntervalPracticeApp)
37
+
38
+ @pytest.mark.usefixtures("init_module")
39
+ def test_create_app_invalid_app_type(self):
40
+ with pytest.raises(ValueError):
41
+ PracticeAppFactory.create_app("invalid", "piece", self.config)
42
+
43
+ @pytest.mark.usefixtures("init_module")
44
+ def test_create_app_invalid_practice_type(self):
45
+ with pytest.raises(ValueError):
46
+ PracticeAppFactory.create_app("web", "invalid", self.config)
tests/domain/composition/test_melody_composer.py CHANGED
@@ -3,6 +3,7 @@
3
  import pytest
4
 
5
  from improvisation_lab.domain.composition.melody_composer import MelodyComposer
 
6
 
7
 
8
  class TestMelodyComposer:
@@ -82,3 +83,24 @@ class TestMelodyComposer:
82
  )
83
  )
84
  assert first_note in adjacent_notes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import pytest
4
 
5
  from improvisation_lab.domain.composition.melody_composer import MelodyComposer
6
+ from improvisation_lab.domain.music_theory import Notes
7
 
8
 
9
  class TestMelodyComposer:
 
83
  )
84
  )
85
  assert first_note in adjacent_notes
86
+
87
+ @pytest.mark.usefixtures("init_module")
88
+ def test_generate_interval_melody(self):
89
+ """Test interval melody generation."""
90
+ base_notes = [Notes.C, Notes.E, Notes.G, Notes.B]
91
+ interval = 2
92
+
93
+ melody = self.melody_composer.generate_interval_melody(base_notes, interval)
94
+
95
+ # Check the length of the melody
96
+ assert len(melody) == len(base_notes)
97
+ assert len(melody[0]) == 3
98
+
99
+ # Check the structure of the melody
100
+ for i, base_note in enumerate(base_notes):
101
+ assert melody[i][0] == base_note
102
+ transposed_note = self.melody_composer.note_transposer.transpose_note(
103
+ base_note, interval
104
+ )
105
+ assert melody[i][1] == transposed_note
106
+ assert melody[i][2] == base_note
tests/domain/composition/test_note_transposer.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from improvisation_lab.domain.composition.note_transposer import NoteTransposer
4
+ from improvisation_lab.domain.music_theory import Notes
5
+
6
+
7
+ class TestNoteTransposer:
8
+ @pytest.fixture
9
+ def init_transposer(self):
10
+ """Initialize NoteTransposer instance for testing."""
11
+ self.transposer = NoteTransposer()
12
+
13
+ @pytest.mark.usefixtures("init_transposer")
14
+ def test_calculate_target_note(self):
15
+ """Test calculation of target note based on interval."""
16
+ # Test cases for different intervals
17
+ test_cases = [
18
+ (Notes.C, 1, Notes.C_SHARP),
19
+ (Notes.C, 4, Notes.E),
20
+ (Notes.B, 1, Notes.C),
21
+ (Notes.E, -1, Notes.D_SHARP),
22
+ (Notes.C, -2, Notes.A_SHARP),
23
+ (Notes.G, 12, Notes.G), # One octave up
24
+ ]
25
+
26
+ for base_note, interval, expected_target in test_cases:
27
+ target_note = self.transposer.transpose_note(base_note, interval)
28
+ assert (
29
+ target_note == expected_target
30
+ ), f"Failed for base_note={base_note}, interval={interval}"
tests/presentation/interval_practice/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Test Package for Interval Practice Presentation Layer."""
tests/presentation/interval_practice/test_console_interval_view.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from improvisation_lab.domain.music_theory import Notes
4
+ from improvisation_lab.presentation.interval_practice.console_interval_view import \
5
+ ConsoleIntervalPracticeView
6
+ from improvisation_lab.presentation.interval_practice.interval_view_text_manager import \
7
+ IntervalViewTextManager # noqa: E501
8
+ from improvisation_lab.service.base_practice_service import PitchResult
9
+
10
+
11
+ class TestConsoleIntervalPracticeView:
12
+ """Tests for the ConsoleIntervalPracticeView class."""
13
+
14
+ @pytest.fixture
15
+ def init_module(self):
16
+ self.text_manager = IntervalViewTextManager()
17
+ self.console_view = ConsoleIntervalPracticeView(self.text_manager)
18
+
19
+ @pytest.mark.usefixtures("init_module")
20
+ def test_launch(self, capsys):
21
+ self.console_view.launch()
22
+ captured = capsys.readouterr()
23
+ assert "Interval Practice:" in captured.out
24
+ assert "Sing each note for 1 second!" in captured.out
25
+
26
+ @pytest.mark.usefixtures("init_module")
27
+ def test_display_phrase_info(self, capsys):
28
+ phrases_data = [[Notes.C, Notes.C_SHARP, Notes.C]]
29
+ self.console_view.display_phrase_info(0, phrases_data)
30
+ captured = capsys.readouterr()
31
+ assert "Problem 1:" in captured.out
32
+ assert "C -> C# -> C" in captured.out
33
+
34
+ @pytest.mark.usefixtures("init_module")
35
+ def test_display_pitch_result(self, capsys):
36
+ pitch_result = PitchResult(
37
+ target_note="C", current_base_note="A", is_correct=False, remaining_time=2.5
38
+ )
39
+ self.console_view.display_pitch_result(pitch_result)
40
+ captured = capsys.readouterr()
41
+ assert "Target: C | Your note: A | Remaining: 2.5s" in captured.out
42
+
43
+ @pytest.mark.usefixtures("init_module")
44
+ def test_display_practice_end(self, capsys):
45
+ self.console_view.display_practice_end()
46
+ captured = capsys.readouterr()
47
+ assert "Session Stopped" in captured.out
tests/presentation/interval_practice/test_interval_view_text_manager.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the ViewTextManager class."""
2
+
3
+ import pytest
4
+
5
+ from improvisation_lab.domain.music_theory import Notes
6
+ from improvisation_lab.presentation.interval_practice.interval_view_text_manager import \
7
+ IntervalViewTextManager # noqa: E501
8
+
9
+
10
+ class TestIntervalViewTextManager:
11
+ """Tests for the IntervalViewTextManager class."""
12
+
13
+ @pytest.fixture
14
+ def init_module(self):
15
+ self.text_manager = IntervalViewTextManager()
16
+
17
+ @pytest.mark.usefixtures("init_module")
18
+ def test_update_phrase_text_no_phrases(self):
19
+ result = self.text_manager.update_phrase_text(0, [])
20
+ assert result == "No phrase data"
21
+ assert self.text_manager.phrase_text == "No phrase data"
22
+
23
+ @pytest.mark.usefixtures("init_module")
24
+ def test_update_phrase_text_with_phrases(self):
25
+ phrases = [
26
+ [Notes.C, Notes.C_SHARP, Notes.C],
27
+ [Notes.A, Notes.A_SHARP, Notes.A],
28
+ ]
29
+ self.text_manager.update_phrase_text(0, phrases)
30
+ expected_text = "Problem 1: \nC -> C# -> C\nNext Base Note: A"
31
+ assert self.text_manager.phrase_text == expected_text
32
+
33
+ self.text_manager.update_phrase_text(1, phrases)
34
+ expected_text = "Problem 2: \nA -> A# -> A"
35
+ assert self.text_manager.phrase_text == expected_text
tests/presentation/interval_practice/test_web_interval_view.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import warnings
2
+ from unittest.mock import Mock, patch
3
+
4
+ import gradio as gr
5
+ import pytest
6
+
7
+ from improvisation_lab.config import Config
8
+ from improvisation_lab.presentation.interval_practice.web_interval_view import \
9
+ WebIntervalPracticeView
10
+
11
+
12
+ class TestWebIntervalPracticeView:
13
+ """Tests for the WebIntervalPracticeView class."""
14
+
15
+ @pytest.fixture
16
+ def init_module(self):
17
+ self.start_callback = Mock(return_value=("-", "Phrase Info", "Note Status"))
18
+ self.stop_callback = Mock(
19
+ return_value=("-", "Session Stopped", "Practice ended")
20
+ )
21
+ self.audio_callback = Mock(
22
+ return_value=("-", "Audio Phrase Info", "Audio Note Status")
23
+ )
24
+ config = Config()
25
+
26
+ self.web_view = WebIntervalPracticeView(
27
+ on_generate_melody=self.start_callback,
28
+ on_end_practice=self.stop_callback,
29
+ on_audio_input=self.audio_callback,
30
+ config=config,
31
+ )
32
+
33
+ @pytest.mark.usefixtures("init_module")
34
+ def test_initialize_interval_settings(self):
35
+ self.web_view._initialize_interval_settings()
36
+ assert self.web_view.init_num_problems == 10
37
+ assert self.web_view.initial_direction == "Up"
38
+ assert self.web_view.initial_interval_key == "minor 2nd"
39
+
40
+ @pytest.mark.usefixtures("init_module")
41
+ def test_build_interface(self):
42
+ warnings.simplefilter("ignore", category=DeprecationWarning)
43
+ app = self.web_view._build_interface()
44
+ assert isinstance(app, gr.Blocks)
45
+
46
+ @pytest.mark.usefixtures("init_module")
47
+ @patch("gradio.Markdown")
48
+ def test_create_header(self, mock_markdown):
49
+ self.web_view._add_header()
50
+ mock_markdown.assert_called_once_with(
51
+ "# Interval Practice\nSing the designated note!"
52
+ )
53
+
54
+ @pytest.mark.usefixtures("init_module")
55
+ def test_create_status_section(self):
56
+ self.web_view._build_interface()
57
+ assert isinstance(self.web_view.base_note_box, gr.Textbox)
58
+ assert isinstance(self.web_view.phrase_info_box, gr.Textbox)
59
+ assert isinstance(self.web_view.pitch_result_box, gr.Textbox)
60
+
61
+ @pytest.mark.usefixtures("init_module")
62
+ def test_create_control_buttons(self):
63
+ self.web_view._build_interface()
64
+ self.web_view.on_generate_melody()
65
+ self.start_callback.assert_called_once()
66
+ self.web_view.on_end_practice()
67
+ self.stop_callback.assert_called_once()
68
+
69
+ @pytest.mark.usefixtures("init_module")
70
+ def test_create_audio_input(self):
71
+ self.web_view._build_interface()
72
+ self.web_view.on_audio_input()
73
+ self.audio_callback.assert_called_once()
74
+
75
+ @pytest.mark.usefixtures("init_module")
76
+ def test_launch(self, mocker):
77
+ mocker.patch.object(gr.Blocks, "launch", return_value=None)
78
+ self.web_view.launch()
79
+ gr.Blocks.launch.assert_called_once()
tests/presentation/piece_practice/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Test Package for Piece Practice Presentation Layer."""
tests/presentation/piece_practice/test_console_piece_view.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from improvisation_lab.domain.composition import PhraseData
4
+ from improvisation_lab.presentation.piece_practice.console_piece_view import \
5
+ ConsolePiecePracticeView
6
+ from improvisation_lab.presentation.piece_practice.piece_view_text_manager import \
7
+ PieceViewTextManager
8
+ from improvisation_lab.service.base_practice_service import PitchResult
9
+
10
+
11
+ class TestConsolePiecePracticeView:
12
+ """Tests for the ConsolePiecePracticeView class."""
13
+
14
+ @pytest.fixture
15
+ def init_module(self):
16
+ self.text_manager = PieceViewTextManager()
17
+ self.console_view = ConsolePiecePracticeView(self.text_manager, "Test Song")
18
+
19
+ @pytest.mark.usefixtures("init_module")
20
+ def test_launch(self, capsys):
21
+ self.console_view.launch()
22
+ captured = capsys.readouterr()
23
+ assert "Generating melody for Test Song:" in captured.out
24
+ assert "Sing each note for 1 second!" in captured.out
25
+
26
+ @pytest.mark.usefixtures("init_module")
27
+ def test_display_phrase_info(self, capsys):
28
+ phrases_data = [
29
+ PhraseData(
30
+ notes=["C", "E", "G"],
31
+ chord_name="Cmaj7",
32
+ scale_info="C major",
33
+ length=4,
34
+ )
35
+ ]
36
+ self.console_view.display_phrase_info(0, phrases_data)
37
+ captured = capsys.readouterr()
38
+ assert "Phrase 1: Cmaj7" in captured.out
39
+ assert "C -> E -> G" in captured.out
40
+
41
+ @pytest.mark.usefixtures("init_module")
42
+ def test_display_pitch_result(self, capsys):
43
+ pitch_result = PitchResult(
44
+ target_note="C", current_base_note="A", is_correct=False, remaining_time=2.5
45
+ )
46
+ self.console_view.display_pitch_result(pitch_result)
47
+ captured = capsys.readouterr()
48
+ assert "Target: C | Your note: A | Remaining: 2.5s" in captured.out
49
+
50
+ @pytest.mark.usefixtures("init_module")
51
+ def test_display_practice_end(self, capsys):
52
+ self.console_view.display_practice_end()
53
+ captured = capsys.readouterr()
54
+ assert "Session Stopped" in captured.out
tests/presentation/piece_practice/test_piece_view_text_manager.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the ViewTextManager class."""
2
+
3
+ import pytest
4
+
5
+ from improvisation_lab.domain.composition import PhraseData
6
+ from improvisation_lab.presentation.piece_practice.piece_view_text_manager import \
7
+ PieceViewTextManager
8
+
9
+
10
+ class TestPieceViewTextManager:
11
+ """Tests for the PieceViewTextManager class."""
12
+
13
+ @pytest.fixture
14
+ def init_module(self):
15
+ self.text_manager = PieceViewTextManager()
16
+
17
+ @pytest.mark.usefixtures("init_module")
18
+ def test_update_phrase_text_no_phrases(self):
19
+ result = self.text_manager.update_phrase_text(0, [])
20
+ assert result == "No phrase data"
21
+ assert self.text_manager.phrase_text == "No phrase data"
22
+
23
+ @pytest.mark.usefixtures("init_module")
24
+ def test_update_phrase_text_with_phrases(self):
25
+ phrases = [
26
+ PhraseData(
27
+ notes=["C", "E", "G"],
28
+ chord_name="Cmaj7",
29
+ scale_info="C major",
30
+ length=4,
31
+ ),
32
+ PhraseData(
33
+ notes=["A", "C", "E"],
34
+ chord_name="Amin7",
35
+ scale_info="A minor",
36
+ length=4,
37
+ ),
38
+ ]
39
+ self.text_manager.update_phrase_text(0, phrases)
40
+ expected_text = "Phrase 1: Cmaj7\nC -> E -> G\nNext: Amin7 (A)"
41
+ assert self.text_manager.phrase_text == expected_text
42
+
43
+ self.text_manager.update_phrase_text(1, phrases)
44
+ expected_text = "Phrase 2: Amin7\nA -> C -> E"
45
+ assert self.text_manager.phrase_text == expected_text
tests/presentation/piece_practice/test_web_piece_view.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import warnings
2
+ from unittest.mock import Mock, patch
3
+
4
+ import gradio as gr
5
+ import pytest
6
+
7
+ from improvisation_lab.presentation.piece_practice.web_piece_view import \
8
+ WebPiecePracticeView
9
+
10
+
11
+ class TestWebPiecePracticeView:
12
+
13
+ @pytest.fixture
14
+ def init_module(self):
15
+ self.start_callback = Mock(return_value=("Phrase Info", "Note Status"))
16
+ self.stop_callback = Mock(return_value=("Session Stopped", "Practice ended"))
17
+ self.audio_callback = Mock(
18
+ return_value=("Audio Phrase Info", "Audio Note Status")
19
+ )
20
+ self.web_view = WebPiecePracticeView(
21
+ on_generate_melody=self.start_callback,
22
+ on_end_practice=self.stop_callback,
23
+ on_audio_input=self.audio_callback,
24
+ song_name="Test Song",
25
+ )
26
+
27
+ @pytest.mark.usefixtures("init_module")
28
+ def test_build_interface(self):
29
+ warnings.simplefilter("ignore", category=DeprecationWarning)
30
+ app = self.web_view._build_interface()
31
+ assert isinstance(app, gr.Blocks)
32
+
33
+ @pytest.mark.usefixtures("init_module")
34
+ @patch("gradio.Markdown")
35
+ def test_create_header(self, mock_markdown):
36
+ self.web_view._add_header()
37
+ mock_markdown.assert_called_once_with(
38
+ "# Test Song Melody Practice\nSing each note for 1 second!"
39
+ )
40
+
41
+ @pytest.mark.usefixtures("init_module")
42
+ def test_create_status_section(self):
43
+ self.web_view._build_interface()
44
+ assert isinstance(self.web_view.phrase_info_box, gr.Textbox)
45
+ assert isinstance(self.web_view.pitch_result_box, gr.Textbox)
46
+
47
+ @pytest.mark.usefixtures("init_module")
48
+ def test_create_control_buttons(self):
49
+ self.web_view._build_interface()
50
+ self.web_view.on_generate_melody()
51
+ self.start_callback.assert_called_once()
52
+ self.web_view.on_end_practice()
53
+ self.stop_callback.assert_called_once()
54
+
55
+ @pytest.mark.usefixtures("init_module")
56
+ def test_create_audio_input(self):
57
+ self.web_view._build_interface()
58
+ self.web_view.on_audio_input()
59
+ self.audio_callback.assert_called_once()
60
+
61
+ @pytest.mark.usefixtures("init_module")
62
+ def test_launch(self, mocker):
63
+ mocker.patch.object(gr.Blocks, "launch", return_value=None)
64
+ self.web_view.launch()
65
+ gr.Blocks.launch.assert_called_once()
tests/presentation/test_view_text_manager.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the ViewTextManager class."""
2
+
3
+ from typing import List, Optional
4
+
5
+ import pytest
6
+
7
+ from improvisation_lab.presentation.view_text_manager import ViewTextManager
8
+ from improvisation_lab.service.base_practice_service import PitchResult
9
+
10
+
11
+ class MockViewTextManager(ViewTextManager):
12
+ """Mock implementation of ViewTextManager for testing."""
13
+
14
+ def __init__(self):
15
+ super().__init__()
16
+
17
+ def update_phrase_text(self, current_phrase_idx: int, phrases: Optional[List]):
18
+ pass
19
+
20
+
21
+ class TestViewTextManager:
22
+
23
+ @pytest.fixture
24
+ def init_module(self):
25
+ self.text_manager = MockViewTextManager()
26
+
27
+ @pytest.mark.usefixtures("init_module")
28
+ def test_initialize_text(self):
29
+ self.text_manager.initialize_text()
30
+ assert self.text_manager.phrase_text == "No phrase data"
31
+ assert self.text_manager.result_text == "Ready to start... (waiting for audio)"
32
+
33
+ @pytest.mark.usefixtures("init_module")
34
+ def test_terminate_text(self):
35
+ self.text_manager.terminate_text()
36
+ assert self.text_manager.phrase_text == "Session Stopped"
37
+ assert self.text_manager.result_text == "Practice ended"
38
+
39
+ @pytest.mark.usefixtures("init_module")
40
+ def test_set_waiting_for_audio(self):
41
+ self.text_manager.set_waiting_for_audio()
42
+ assert self.text_manager.result_text == "Waiting for audio..."
43
+
44
+ @pytest.mark.usefixtures("init_module")
45
+ def test_update_pitch_result(self):
46
+ pitch_result = PitchResult(
47
+ target_note="C", current_base_note="A", is_correct=False, remaining_time=2.5
48
+ )
49
+ self.text_manager.update_pitch_result(pitch_result)
50
+ assert (
51
+ self.text_manager.result_text
52
+ == "Target: C | Your note: A | Remaining: 2.5s"
53
+ )
tests/service/test_base_practice_service.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for BasePracticeService."""
2
+
3
+ import time
4
+
5
+ import numpy as np
6
+ import pytest
7
+
8
+ from improvisation_lab.config import Config
9
+ from improvisation_lab.service.base_practice_service import PitchResult
10
+ from improvisation_lab.service.piece_practice_service import \
11
+ BasePracticeService
12
+
13
+
14
+ class MockBasePracticeService(BasePracticeService):
15
+ def generate_melody(self):
16
+ pass
17
+
18
+
19
+ class TestBasePracticeService:
20
+ @pytest.fixture
21
+ def init_module(self):
22
+ """Create BasePracticeService instance for testing."""
23
+ config = Config()
24
+ self.service = MockBasePracticeService(config)
25
+
26
+ @pytest.mark.usefixtures("init_module")
27
+ def test_process_audio_no_voice(self):
28
+ """Test processing audio with no voice detected."""
29
+ audio_data = np.zeros(1024, dtype=np.float32)
30
+ result = self.service.process_audio(audio_data, target_note="A")
31
+
32
+ assert isinstance(result, PitchResult)
33
+ assert result.current_base_note is None
34
+ assert not result.is_correct
35
+
36
+ @pytest.mark.usefixtures("init_module")
37
+ def test_process_audio_with_voice(self):
38
+ """Test processing audio with voice detected."""
39
+ sample_rate = 44100
40
+ duration = 0.1
41
+ t = np.linspace(0, duration, int(sample_rate * duration))
42
+ audio_data = np.sin(2 * np.pi * 440 * t)
43
+
44
+ result = self.service.process_audio(audio_data, target_note="A")
45
+
46
+ assert isinstance(result, PitchResult)
47
+ assert result.current_base_note == "A"
48
+ assert result.is_correct
49
+
50
+ @pytest.mark.usefixtures("init_module")
51
+ def test_process_audio_incorrect_pitch(self):
52
+ """Test processing audio with incorrect pitch."""
53
+ sample_rate = 44100
54
+ duration = 0.1
55
+ t = np.linspace(0, duration, int(sample_rate * duration))
56
+ # Generate 440Hz (A4) when target is C4
57
+ audio_data = np.sin(2 * np.pi * 440 * t)
58
+
59
+ result = self.service.process_audio(audio_data, target_note="C")
60
+
61
+ assert isinstance(result, PitchResult)
62
+ assert result.current_base_note == "A"
63
+ assert not result.is_correct
64
+ assert result.remaining_time == self.service.config.audio.note_duration
65
+
66
+ @pytest.mark.usefixtures("init_module")
67
+ def test_correct_pitch_timing(self):
68
+ """Test timing behavior with correct pitch."""
69
+ sample_rate = 44100
70
+ duration = 0.1
71
+ t = np.linspace(0, duration, int(sample_rate * duration))
72
+ audio_data = np.sin(2 * np.pi * 440 * t)
73
+
74
+ # First detection
75
+ result1 = self.service.process_audio(audio_data, target_note="A")
76
+ initial_time = self.service.correct_pitch_start_time
77
+ assert result1.is_correct
78
+ assert result1.remaining_time == self.service.config.audio.note_duration
79
+
80
+ # Wait a bit
81
+ time.sleep(0.5)
82
+
83
+ # Second detection
84
+ result2 = self.service.process_audio(audio_data, target_note="A")
85
+ assert result2.is_correct
86
+ assert result2.remaining_time < self.service.config.audio.note_duration
87
+ assert initial_time == self.service.correct_pitch_start_time
88
+
89
+ @pytest.mark.usefixtures("init_module")
90
+ def test_correct_pitch_completion(self):
91
+ """Test completion of correct pitch duration."""
92
+ sample_rate = 44100
93
+ duration = 0.1
94
+ t = np.linspace(0, duration, int(sample_rate * duration))
95
+ audio_data = np.sin(2 * np.pi * 440 * t)
96
+
97
+ # First detection
98
+ result1 = self.service.process_audio(audio_data, target_note="A")
99
+ assert result1.remaining_time == self.service.config.audio.note_duration
100
+
101
+ # Wait for full duration
102
+ time.sleep(self.service.config.audio.note_duration + 0.1)
103
+
104
+ # Final detection
105
+ result2 = self.service.process_audio(audio_data, target_note="A")
106
+ assert result2.remaining_time == 0