Spaces:
Sleeping
Sleeping
atsushieee
commited on
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- config.yml.example +13 -8
- improvisation_lab/application/__init__.py +4 -0
- improvisation_lab/application/app_factory.py +43 -0
- improvisation_lab/application/base_app.py +51 -0
- improvisation_lab/application/base_console_app.py +97 -0
- improvisation_lab/application/interval_practice/__init__.py +6 -0
- improvisation_lab/application/interval_practice/console_interval_app.py +49 -0
- improvisation_lab/application/interval_practice/web_interval_app.py +164 -0
- improvisation_lab/application/piece_practice/__init__.py +8 -0
- improvisation_lab/application/piece_practice/console_piece_app.py +38 -0
- improvisation_lab/application/piece_practice/web_piece_app.py +122 -0
- improvisation_lab/config.py +44 -7
- improvisation_lab/domain/composition/melody_composer.py +21 -1
- improvisation_lab/domain/composition/note_transposer.py +17 -0
- improvisation_lab/domain/music_theory.py +23 -0
- improvisation_lab/presentation/console_view.py +55 -0
- improvisation_lab/presentation/interval_practice/__init__.py +12 -0
- improvisation_lab/presentation/interval_practice/console_interval_view.py +26 -0
- improvisation_lab/presentation/interval_practice/interval_view_text_manager.py +40 -0
- improvisation_lab/presentation/interval_practice/web_interval_view.py +167 -0
- improvisation_lab/presentation/piece_practice/__init__.py +14 -0
- improvisation_lab/presentation/piece_practice/console_piece_view.py +28 -0
- improvisation_lab/presentation/piece_practice/piece_view_text_manager.py +44 -0
- improvisation_lab/presentation/piece_practice/web_piece_view.py +90 -0
- improvisation_lab/presentation/view_text_manager.py +56 -0
- improvisation_lab/presentation/web_view.py +51 -0
- improvisation_lab/service/__init__.py +5 -3
- improvisation_lab/service/base_practice_service.py +125 -0
- improvisation_lab/service/interval_practice_service.py +31 -0
- improvisation_lab/service/piece_practice_service.py +24 -0
- main.py +8 -5
- tests/application/interval_practice/__init__.py +1 -0
- tests/application/interval_practice/test_console_interval_app.py +68 -0
- tests/application/interval_practice/test_web_interval_app.py +100 -0
- tests/application/piece_practice/__init__.py +1 -0
- tests/application/piece_practice/test_console_piece_app.py +71 -0
- tests/application/piece_practice/test_web_piece_app.py +94 -0
- tests/application/test_app_factory.py +46 -0
- tests/domain/composition/test_melody_composer.py +22 -0
- tests/domain/composition/test_note_transposer.py +30 -0
- tests/presentation/interval_practice/__init__.py +1 -0
- tests/presentation/interval_practice/test_console_interval_view.py +47 -0
- tests/presentation/interval_practice/test_interval_view_text_manager.py +35 -0
- tests/presentation/interval_practice/test_web_interval_view.py +79 -0
- tests/presentation/piece_practice/__init__.py +1 -0
- tests/presentation/piece_practice/test_console_piece_view.py +54 -0
- tests/presentation/piece_practice/test_piece_view_text_manager.py +45 -0
- tests/presentation/piece_practice/test_web_piece_view.py +65 -0
- tests/presentation/test_view_text_manager.py +53 -0
- 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 |
-
|
|
|
|
|
13 |
|
14 |
-
|
15 |
-
fly_me_to_the_moon
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
|
|
|
|
|
|
|
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 |
-
|
57 |
-
|
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.
|
74 |
-
"
|
|
|
|
|
|
|
75 |
)
|
76 |
-
self.chord_progressions = yaml_data.get("chord_progressions", {})
|
77 |
else:
|
78 |
self.audio = AudioConfig()
|
79 |
-
self.
|
80 |
-
self.
|
|
|
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.
|
4 |
-
|
|
|
|
|
5 |
|
6 |
-
__all__ = ["
|
|
|
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
|
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 |
-
|
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
|