atsushieee commited on
Commit
c1e08a0
·
verified ·
1 Parent(s): 4ca706f

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .github/workflows/deploy_to_hf.yml +55 -0
  2. .gitignore +165 -0
  3. Makefile +33 -0
  4. README.md +141 -5
  5. config.yml.example +20 -0
  6. hf_space_metadata.yml +12 -0
  7. improvisation_lab/__init__.py +1 -0
  8. improvisation_lab/application/__init__.py +1 -0
  9. improvisation_lab/application/melody_practice/__init__.py +6 -0
  10. improvisation_lab/application/melody_practice/app_factory.py +28 -0
  11. improvisation_lab/application/melody_practice/base_app.py +53 -0
  12. improvisation_lab/application/melody_practice/console_app.py +82 -0
  13. improvisation_lab/application/melody_practice/web_app.py +120 -0
  14. improvisation_lab/config.py +89 -0
  15. improvisation_lab/domain/__init__.py +1 -0
  16. improvisation_lab/domain/analysis/__init__.py +5 -0
  17. improvisation_lab/domain/analysis/pitch_detector.py +61 -0
  18. improvisation_lab/domain/composition/__init__.py +6 -0
  19. improvisation_lab/domain/composition/melody_composer.py +71 -0
  20. improvisation_lab/domain/composition/phrase_generator.py +188 -0
  21. improvisation_lab/domain/music_theory.py +172 -0
  22. improvisation_lab/infrastructure/__init__.py +1 -0
  23. improvisation_lab/infrastructure/audio/__init__.py +10 -0
  24. improvisation_lab/infrastructure/audio/audio_processor.py +53 -0
  25. improvisation_lab/infrastructure/audio/direct_processor.py +104 -0
  26. improvisation_lab/infrastructure/audio/web_processor.py +112 -0
  27. improvisation_lab/presentation/__init__.py +1 -0
  28. improvisation_lab/presentation/melody_practice/__init__.py +14 -0
  29. improvisation_lab/presentation/melody_practice/console_melody_view.py +56 -0
  30. improvisation_lab/presentation/melody_practice/view_text_manager.py +70 -0
  31. improvisation_lab/presentation/melody_practice/web_melody_view.py +99 -0
  32. improvisation_lab/service/__init__.py +6 -0
  33. improvisation_lab/service/melody_practice_service.py +128 -0
  34. main.py +33 -0
  35. packages.txt +1 -0
  36. poetry.lock +0 -0
  37. pyproject.toml +45 -0
  38. requirements.txt +77 -0
  39. scripts/__init__.py +1 -0
  40. scripts/pitch_detection_demo.py +141 -0
  41. tests/__init__.py +1 -0
  42. tests/application/__init__.py +1 -0
  43. tests/application/melody_practice/__init__.py +1 -0
  44. tests/application/melody_practice/test_app_factory.py +34 -0
  45. tests/application/melody_practice/test_console_app.py +71 -0
  46. tests/application/melody_practice/test_web_app.py +94 -0
  47. tests/domain/__init__.py +1 -0
  48. tests/domain/analysis/__init__.py +1 -0
  49. tests/domain/analysis/test_pitch_detector.py +49 -0
  50. tests/domain/composition/__init__.py +1 -0
.github/workflows/deploy_to_hf.yml ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ name: Deploy to Hugging Face Spaces
3
+
4
+ on:
5
+ push:
6
+ branches:
7
+ - main
8
+
9
+ jobs:
10
+ deploy:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - name: Check out the repository
15
+ uses: actions/checkout@v3
16
+
17
+ - name: Set up Python
18
+ uses: actions/setup-python@v4
19
+ with:
20
+ python-version: '3.11'
21
+
22
+ - name: Install Poetry
23
+ run: |
24
+ curl -sSL https://install.python-poetry.org | python3 -
25
+ echo "${{ runner.tool_cache }}/poetry/bin" >> $GITHUB_PATH
26
+
27
+ - name: Export requirements.txt
28
+ run: poetry export -f requirements.txt --output requirements.txt --without-hashes
29
+
30
+ - name: Create packages.txt
31
+ run: |
32
+ echo "portaudio19-dev" > packages.txt
33
+
34
+ - name: Prepend YAML header to README
35
+ run: |
36
+ cat hf_space_metadata.yml README.md > new_readme.md
37
+ mv new_readme.md README.md
38
+
39
+ - name: Install Hugging Face CLI
40
+ run: |
41
+ python -m pip install --upgrade pip
42
+ pip install huggingface_hub
43
+
44
+ - name: Configure Hugging Face CLI
45
+ env:
46
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
47
+ run: |
48
+ huggingface-cli login --token $HF_TOKEN
49
+
50
+ - name: Deploy to Spaces
51
+ env:
52
+ HF_USERNAME: ${{ secrets.HF_USERNAME }}
53
+ SPACE_NAME: ${{ secrets.SPACE_NAME }}
54
+ run: |
55
+ huggingface-cli upload atsushieee/improvisation-lab . --repo-type=space
.gitignore ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ignore config.yml
2
+ config.yml
3
+
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+
9
+ # C extensions
10
+ *.so
11
+
12
+ # Distribution / packaging
13
+ .Python
14
+ build/
15
+ develop-eggs/
16
+ dist/
17
+ downloads/
18
+ eggs/
19
+ .eggs/
20
+ lib/
21
+ lib64/
22
+ parts/
23
+ sdist/
24
+ var/
25
+ wheels/
26
+ share/python-wheels/
27
+ *.egg-info/
28
+ .installed.cfg
29
+ *.egg
30
+ MANIFEST
31
+
32
+ # PyInstaller
33
+ # Usually these files are written by a python script from a template
34
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
35
+ *.manifest
36
+ *.spec
37
+
38
+ # Installer logs
39
+ pip-log.txt
40
+ pip-delete-this-directory.txt
41
+
42
+ # Unit test / coverage reports
43
+ htmlcov/
44
+ .tox/
45
+ .nox/
46
+ .coverage
47
+ .coverage.*
48
+ .cache
49
+ nosetests.xml
50
+ coverage.xml
51
+ *.cover
52
+ *.py,cover
53
+ .hypothesis/
54
+ .pytest_cache/
55
+ cover/
56
+
57
+ # Translations
58
+ *.mo
59
+ *.pot
60
+
61
+ # Django stuff:
62
+ *.log
63
+ local_settings.py
64
+ db.sqlite3
65
+ db.sqlite3-journal
66
+
67
+ # Flask stuff:
68
+ instance/
69
+ .webassets-cache
70
+
71
+ # Scrapy stuff:
72
+ .scrapy
73
+
74
+ # Sphinx documentation
75
+ docs/_build/
76
+
77
+ # PyBuilder
78
+ .pybuilder/
79
+ target/
80
+
81
+ # Jupyter Notebook
82
+ .ipynb_checkpoints
83
+
84
+ # IPython
85
+ profile_default/
86
+ ipython_config.py
87
+
88
+ # pyenv
89
+ # For a library or package, you might want to ignore these files since the code is
90
+ # intended to run in multiple environments; otherwise, check them in:
91
+ # .python-version
92
+
93
+ # pipenv
94
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
95
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
96
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
97
+ # install all needed dependencies.
98
+ #Pipfile.lock
99
+
100
+ # poetry
101
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
102
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
103
+ # commonly ignored for libraries.
104
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
105
+ #poetry.lock
106
+
107
+ # pdm
108
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
109
+ #pdm.lock
110
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
111
+ # in version control.
112
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
113
+ .pdm.toml
114
+ .pdm-python
115
+ .pdm-build/
116
+
117
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
118
+ __pypackages__/
119
+
120
+ # Celery stuff
121
+ celerybeat-schedule
122
+ celerybeat.pid
123
+
124
+ # SageMath parsed files
125
+ *.sage.py
126
+
127
+ # Environments
128
+ .env
129
+ .venv
130
+ env/
131
+ venv/
132
+ ENV/
133
+ env.bak/
134
+ venv.bak/
135
+
136
+ # Spyder project settings
137
+ .spyderproject
138
+ .spyproject
139
+
140
+ # Rope project settings
141
+ .ropeproject
142
+
143
+ # mkdocs documentation
144
+ /site
145
+
146
+ # mypy
147
+ .mypy_cache/
148
+ .dmypy.json
149
+ dmypy.json
150
+
151
+ # Pyre type checker
152
+ .pyre/
153
+
154
+ # pytype static type analyzer
155
+ .pytype/
156
+
157
+ # Cython debug symbols
158
+ cython_debug/
159
+
160
+ # PyCharm
161
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
162
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
163
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
164
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
165
+ #.idea/
Makefile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .PHONY: install
2
+ install:
3
+ poetry install
4
+
5
+ .PHONY: run
6
+ run:
7
+ poetry run python main.py
8
+
9
+ .PHONY: lint
10
+ lint:
11
+ poetry run pflake8 improvisation_lab scripts tests main.py
12
+ poetry run mypy improvisation_lab scripts tests main.py
13
+ poetry run pydocstyle improvisation_lab scripts tests main.py
14
+
15
+ .PHONY: format
16
+ format:
17
+ poetry run black improvisation_lab scripts tests main.py
18
+ poetry run isort improvisation_lab scripts tests main.py
19
+
20
+ .PHONY: test
21
+ test:
22
+ poetry run pytest -vs tests
23
+
24
+ .PHONY: pitch-demo-web pitch-demo-direct
25
+ pitch-demo-web:
26
+ poetry run python scripts/pitch_detection_demo.py --input web
27
+
28
+ pitch-demo-direct:
29
+ poetry run python scripts/pitch_detection_demo.py --input direct
30
+
31
+ # Target alias (Default: input voice via web)
32
+ .PHONY: pitch-demo
33
+ pitch-demo: pitch-demo-web
README.md CHANGED
@@ -1,12 +1,148 @@
1
  ---
2
  title: Improvisation Lab
3
- emoji: 🌖
4
- colorFrom: purple
5
- colorTo: blue
 
6
  sdk: gradio
7
  sdk_version: 5.7.1
8
- app_file: app.py
9
  pinned: false
 
10
  ---
 
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Improvisation Lab
3
+ emoji: 🎵
4
+ python_version: 3.11
5
+ colorFrom: blue
6
+ colorTo: purple
7
  sdk: gradio
8
  sdk_version: 5.7.1
9
+ app_file: main.py
10
  pinned: false
11
+ license: mit
12
  ---
13
+ # Improvisation Lab
14
 
15
+ A Python package for generating musical improvisation melodies based on music theory principles. The package specializes in creating natural-sounding melodic phrases that follow chord progressions while respecting musical rules, with real-time pitch detection for practice feedback.
16
+
17
+ Improvisation Lab Demo
18
+
19
+ https://github.com/user-attachments/assets/a4207f7e-166c-4f50-9c19-5bf5269fd04e
20
+
21
+
22
+ ## Features
23
+
24
+ - Generate melodic phrases based on scales and chord progressions
25
+ - Support for multiple scale types:
26
+ - Major
27
+ - Natural minor
28
+ - Harmonic minor
29
+ - Diminished
30
+ - Support for various chord types:
31
+ - Major 7th (maj7)
32
+ - Minor 7th (min7)
33
+ - Dominant 7th (dom7)
34
+ - Half-diminished (min7b5)
35
+ - Diminished 7th (dim7)
36
+ - Intelligent note selection based on:
37
+ - Chord tones vs non-chord tones
38
+ - Scale degrees
39
+ - Previous note context
40
+ - Real-time pitch detection with FCPE (Fast Context-aware Pitch Estimation)
41
+ - Web-based and direct microphone input support
42
+
43
+ ## Prerequisites
44
+
45
+ - Python 3.11 or higher
46
+ - A working microphone
47
+ - [Poetry](https://python-poetry.org/) for dependency management
48
+
49
+ ## Installation
50
+ ```bash
51
+ make install
52
+ ```
53
+
54
+ ## Quick Start
55
+ 1. Create your configuration file:
56
+
57
+ ```bash
58
+ cp config.yml.example config.yml
59
+ ```
60
+
61
+ 2. (Optional) Edit `config.yml` to customize settings like audio parameters and song selection
62
+
63
+ 3. Run the script to start the melody generation and playback (default is web interface):
64
+
65
+ ```bash
66
+ make run
67
+ ```
68
+
69
+ - To run the console interface, use:
70
+
71
+ ```bash
72
+ poetry run python main.py --app_type console
73
+ ```
74
+
75
+ 4. Follow the displayed melody phrases and sing along with real-time feedback
76
+
77
+ ### Configuration
78
+
79
+ The application can be customized through `config.yml` with the following options:
80
+
81
+ #### Audio Settings
82
+ - `sample_rate`: Audio sampling rate (default: 44100 Hz)
83
+ - `buffer_duration`: Duration of audio processing buffer (default: 0.2 seconds)
84
+ - `note_duration`: How long to display each note during practice (default: 3 seconds)
85
+ - `pitch_detector`: Configuration for the pitch detection algorithm
86
+ - `hop_length`: Hop length for the pitch detection algorithm (default: 512)
87
+ - `threshold`: Threshold for the pitch detection algorithm (default: 0.006)
88
+ - `f0_min`: Minimum frequency for the pitch detection algorithm (default: 80 Hz)
89
+ - `f0_max`: Maximum frequency for the pitch detection algorithm (default: 880 Hz)
90
+ - `device`: Device to use for the pitch detection algorithm (default: "cpu")
91
+
92
+ #### Song Selection
93
+ - `selected_song`: Name of the song to practice
94
+ - `chord_progressions`: Dictionary of songs and their progressions
95
+ - Format: `[scale_root, scale_type, chord_root, chord_type, duration]`
96
+ - Example:
97
+ ```yaml
98
+ fly_me_to_the_moon:
99
+ - ["A", "natural_minor", "A", "min7", 4]
100
+ - ["A", "natural_minor", "D", "min7", 4]
101
+ - ["C", "major", "G", "dom7", 4]
102
+ ```
103
+
104
+
105
+ ## How It Works
106
+
107
+ ### Melody Generation
108
+ The melody generation follows these principles:
109
+ 1. Notes are selected based on their relationship to the current chord and scale
110
+ 2. Chord tones have more freedom in movement
111
+ 3. Non-chord tones are restricted to moving to adjacent scale notes
112
+ 4. Phrases are connected naturally by considering the previous note
113
+ 5. All generated notes stay within the specified scale
114
+
115
+ ### Real-time Feedback
116
+ Pitch Detection Demo:
117
+
118
+ https://github.com/user-attachments/assets/fd9e6e3f-85f1-42be-a6c8-b757da478854
119
+
120
+ The application provides real-time feedback by:
121
+ 1. Capturing audio from your microphone
122
+ 2. Detecting the pitch using FCPE (Fast Context-aware Pitch Estimation)
123
+ 3. Converting the frequency to the nearest musical note
124
+ 4. Displaying both the target note and your sung note in real-time
125
+
126
+ ## Development
127
+ ### Running Lint
128
+ ```bash
129
+ make lint
130
+ ```
131
+
132
+ ### Running Format
133
+ ```bash
134
+ make format
135
+ ```
136
+
137
+ ### Running Tests
138
+ ```bash
139
+ make test
140
+ ```
141
+
142
+ ## License
143
+
144
+ MIT License
145
+
146
+ ## Contributing
147
+
148
+ Contributions are welcome! Please feel free to submit a Pull Request.
config.yml.example ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ audio:
2
+ sample_rate: 44100
3
+ buffer_duration: 0.2
4
+ note_duration: 1.0
5
+ pitch_detector:
6
+ hop_length: 512
7
+ threshold: 0.006
8
+ f0_min: 80
9
+ f0_max: 880
10
+ device: "cpu"
11
+
12
+ selected_song: "fly_me_to_the_moon"
13
+
14
+ chord_progressions:
15
+ fly_me_to_the_moon:
16
+ - ["A", "natural_minor", "A", "min7", 4]
17
+ - ["A", "natural_minor", "D", "min7", 4]
18
+ - ["C", "major", "G", "dom7", 4]
19
+ - ["C", "major", "C", "maj7", 2]
20
+ - ["F", "major", "C", "dom7", 2]
hf_space_metadata.yml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Improvisation Lab
3
+ emoji: 🎵
4
+ python_version: 3.11
5
+ colorFrom: blue
6
+ colorTo: purple
7
+ sdk: gradio
8
+ sdk_version: 5.7.1
9
+ app_file: main.py
10
+ pinned: false
11
+ license: mit
12
+ ---
improvisation_lab/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Improvisation Lab - A Python package for musical improvisation."""
improvisation_lab/application/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Application layer for the Improvisation Lab."""
improvisation_lab/application/melody_practice/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Application layer for melody practice."""
2
+
3
+ from improvisation_lab.application.melody_practice.app_factory import \
4
+ MelodyPracticeAppFactory
5
+
6
+ __all__ = ["MelodyPracticeAppFactory"]
improvisation_lab/application/melody_practice/app_factory.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Factory class for creating melody practice applications."""
2
+
3
+ from improvisation_lab.application.melody_practice.console_app import \
4
+ ConsoleMelodyPracticeApp
5
+ from improvisation_lab.application.melody_practice.web_app import \
6
+ WebMelodyPracticeApp
7
+ from improvisation_lab.config import Config
8
+ from improvisation_lab.service import MelodyPracticeService
9
+
10
+
11
+ class MelodyPracticeAppFactory:
12
+ """Factory class for creating melody practice applications."""
13
+
14
+ @staticmethod
15
+ def create_app(app_type: str, service: MelodyPracticeService, config: Config):
16
+ """Create a melody practice application.
17
+
18
+ Args:
19
+ app_type: Type of application to create.
20
+ service: MelodyPracticeService instance.
21
+ config: Config instance.
22
+ """
23
+ if app_type == "web":
24
+ return WebMelodyPracticeApp(service, config)
25
+ elif app_type == "console":
26
+ return ConsoleMelodyPracticeApp(service, config)
27
+ else:
28
+ raise ValueError(f"Unknown app type: {app_type}")
improvisation_lab/application/melody_practice/base_app.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.presentation.melody_practice import ViewTextManager
11
+ from improvisation_lab.service import MelodyPracticeService
12
+
13
+
14
+ class BaseMelodyPracticeApp(ABC):
15
+ """Base class for melody practice applications."""
16
+
17
+ def __init__(self, service: MelodyPracticeService, config: Config):
18
+ """Initialize the application.
19
+
20
+ Args:
21
+ service: MelodyPracticeService instance.
22
+ config: Config instance.
23
+ """
24
+ self.service = service
25
+ self.config = config
26
+ self.phrases: Optional[List[PhraseData]] = None
27
+ self.current_phrase_idx: int = 0
28
+ self.current_note_idx: int = 0
29
+ self.is_running: bool = False
30
+ self.text_manager = ViewTextManager()
31
+
32
+ @abstractmethod
33
+ def _process_audio_callback(self, audio_data: np.ndarray):
34
+ """Process incoming audio data and update the application state.
35
+
36
+ Args:
37
+ audio_data: Audio data to process.
38
+ """
39
+ pass
40
+
41
+ @abstractmethod
42
+ def _advance_to_next_note(self):
43
+ """Advance to the next note or phrase."""
44
+ pass
45
+
46
+ @abstractmethod
47
+ def launch(self, **kwargs):
48
+ """Launch the application.
49
+
50
+ Args:
51
+ **kwargs: Additional keyword arguments for the launch method.
52
+ """
53
+ pass
improvisation_lab/application/melody_practice/console_app.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Console application for melody practice."""
2
+
3
+ import time
4
+
5
+ import numpy as np
6
+
7
+ from improvisation_lab.application.melody_practice.base_app import \
8
+ BaseMelodyPracticeApp
9
+ from improvisation_lab.config import Config
10
+ from improvisation_lab.infrastructure.audio import DirectAudioProcessor
11
+ from improvisation_lab.presentation.melody_practice import ConsoleMelodyView
12
+ from improvisation_lab.service import MelodyPracticeService
13
+
14
+
15
+ class ConsoleMelodyPracticeApp(BaseMelodyPracticeApp):
16
+ """Main application class for melody practice."""
17
+
18
+ def __init__(self, service: MelodyPracticeService, config: Config):
19
+ """Initialize the application using console UI.
20
+
21
+ Args:
22
+ service: MelodyPracticeService instance.
23
+ config: Config instance.
24
+ """
25
+ super().__init__(service, config)
26
+
27
+ self.audio_processor = DirectAudioProcessor(
28
+ sample_rate=config.audio.sample_rate,
29
+ callback=self._process_audio_callback,
30
+ buffer_duration=config.audio.buffer_duration,
31
+ )
32
+
33
+ self.ui = ConsoleMelodyView(self.text_manager, config.selected_song)
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_phrase = self.phrases[self.current_phrase_idx]
44
+ current_note = current_phrase.notes[self.current_note_idx]
45
+
46
+ result = self.service.process_audio(audio_data, current_note)
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.phrases[self.current_phrase_idx].notes):
59
+ self.current_note_idx = 0
60
+ self.current_phrase_idx += 1
61
+ self.ui.display_phrase_info(self.current_phrase_idx, self.phrases)
62
+ if self.current_phrase_idx >= len(self.phrases):
63
+ self.current_phrase_idx = 0
64
+
65
+ def launch(self):
66
+ """Launch the application."""
67
+ self.ui.launch()
68
+ self.phrases = self.service.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()
improvisation_lab/application/melody_practice/web_app.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Web application for melody practice."""
2
+
3
+ import numpy as np
4
+
5
+ from improvisation_lab.application.melody_practice.base_app import \
6
+ BaseMelodyPracticeApp
7
+ from improvisation_lab.config import Config
8
+ from improvisation_lab.infrastructure.audio import WebAudioProcessor
9
+ from improvisation_lab.presentation.melody_practice import WebMelodyView
10
+ from improvisation_lab.service import MelodyPracticeService
11
+
12
+
13
+ class WebMelodyPracticeApp(BaseMelodyPracticeApp):
14
+ """Main application class for melody practice."""
15
+
16
+ def __init__(self, service: MelodyPracticeService, config: Config):
17
+ """Initialize the application using web UI.
18
+
19
+ Args:
20
+ service: MelodyPracticeService instance.
21
+ config: Config instance.
22
+ """
23
+ super().__init__(service, config)
24
+
25
+ self.audio_processor = WebAudioProcessor(
26
+ sample_rate=config.audio.sample_rate,
27
+ callback=self._process_audio_callback,
28
+ buffer_duration=config.audio.buffer_duration,
29
+ )
30
+
31
+ # UIをコールバック関数と共に初期化
32
+ self.ui = WebMelodyView(
33
+ on_generate_melody=self.start,
34
+ on_end_practice=self.stop,
35
+ on_audio_input=self.handle_audio,
36
+ song_name=config.selected_song,
37
+ )
38
+
39
+ def _process_audio_callback(self, audio_data: np.ndarray):
40
+ """Process incoming audio data and update the application state.
41
+
42
+ Args:
43
+ audio_data: Audio data to process.
44
+ """
45
+ if not self.is_running or not self.phrases:
46
+ return
47
+
48
+ current_phrase = self.phrases[self.current_phrase_idx]
49
+ current_note = current_phrase.notes[self.current_note_idx]
50
+
51
+ result = self.service.process_audio(audio_data, current_note)
52
+
53
+ # Update status display
54
+ self.text_manager.update_pitch_result(result)
55
+
56
+ # Progress to next note if current note is complete
57
+ if result.remaining_time <= 0:
58
+ self._advance_to_next_note()
59
+
60
+ self.text_manager.update_phrase_text(self.current_phrase_idx, self.phrases)
61
+
62
+ def _advance_to_next_note(self):
63
+ """Advance to the next note or phrase."""
64
+ if self.phrases is None:
65
+ return
66
+ self.current_note_idx += 1
67
+ if self.current_note_idx >= len(self.phrases[self.current_phrase_idx].notes):
68
+ self.current_note_idx = 0
69
+ self.current_phrase_idx += 1
70
+ if self.current_phrase_idx >= len(self.phrases):
71
+ self.current_phrase_idx = 0
72
+
73
+ def handle_audio(self, audio: tuple[int, np.ndarray]) -> tuple[str, str]:
74
+ """Handle audio input from Gradio interface.
75
+
76
+ Args:
77
+ audio: Audio data to process.
78
+
79
+ Returns:
80
+ tuple[str, str]: The current phrase text and result text.
81
+ """
82
+ if not self.is_running:
83
+ return "Not running", "Start the session first"
84
+
85
+ self.audio_processor.process_audio(audio)
86
+ return self.text_manager.phrase_text, self.text_manager.result_text
87
+
88
+ def start(self) -> tuple[str, str]:
89
+ """Start a new practice session.
90
+
91
+ Returns:
92
+ tuple[str, str]: The current phrase text and result text.
93
+ """
94
+ self.phrases = self.service.generate_melody()
95
+ self.current_phrase_idx = 0
96
+ self.current_note_idx = 0
97
+ self.is_running = True
98
+
99
+ if not self.audio_processor.is_recording:
100
+ self.text_manager.initialize_text()
101
+ self.audio_processor.start_recording()
102
+
103
+ self.text_manager.update_phrase_text(self.current_phrase_idx, self.phrases)
104
+ return self.text_manager.phrase_text, self.text_manager.result_text
105
+
106
+ def stop(self) -> tuple[str, str]:
107
+ """Stop the current practice session.
108
+
109
+ Returns:
110
+ tuple[str, str]: The current phrase text and result text.
111
+ """
112
+ self.is_running = False
113
+ if self.audio_processor.is_recording:
114
+ self.audio_processor.stop_recording()
115
+ self.text_manager.terminate_text()
116
+ return self.text_manager.phrase_text, self.text_manager.result_text
117
+
118
+ def launch(self, **kwargs):
119
+ """Launch the application."""
120
+ self.ui.launch(**kwargs)
improvisation_lab/config.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration module for audio settings and chord progressions."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+
9
+ @dataclass
10
+ class PitchDetectorConfig:
11
+ """Configuration settings for pitch detection."""
12
+
13
+ sample_rate: int = 44100
14
+ hop_length: int = 512
15
+ decoder_mode: str = "local_argmax"
16
+ threshold: float = 0.006
17
+ f0_min: int = 80
18
+ f0_max: int = 880
19
+ interp_uv: bool = False
20
+ device: str = "cpu"
21
+
22
+
23
+ @dataclass
24
+ class AudioConfig:
25
+ """Configuration class for audio-related settings."""
26
+
27
+ sample_rate: int = 44100
28
+ buffer_duration: float = 0.2
29
+ note_duration: float = 1.0
30
+ pitch_detector: PitchDetectorConfig = field(default_factory=PitchDetectorConfig)
31
+
32
+ @classmethod
33
+ def from_yaml(cls, yaml_data: dict) -> "AudioConfig":
34
+ """Create AudioConfig instance from YAML data."""
35
+ config = cls(
36
+ sample_rate=yaml_data.get("sample_rate", cls.sample_rate),
37
+ buffer_duration=yaml_data.get("buffer_duration", cls.buffer_duration),
38
+ note_duration=yaml_data.get("note_duration", cls.note_duration),
39
+ )
40
+
41
+ if "pitch_detector" in yaml_data:
42
+ pitch_detector_data = yaml_data["pitch_detector"]
43
+ # The sample rate must be set explicitly
44
+ # Use the sample rate specified in the audio config
45
+ pitch_detector_data["sample_rate"] = config.sample_rate
46
+ config.pitch_detector = PitchDetectorConfig(**pitch_detector_data)
47
+
48
+ return config
49
+
50
+
51
+ @dataclass
52
+ class Config:
53
+ """Application configuration handler."""
54
+
55
+ audio: AudioConfig
56
+ selected_song: str
57
+ chord_progressions: dict
58
+
59
+ def __init__(self, config_path: str | Path = "config.yml"):
60
+ """Initialize Config instance.
61
+
62
+ Args:
63
+ config_path: Path to YAML configuration file (default: 'config.yml').
64
+ """
65
+ self.config_path = Path(config_path)
66
+ self._load_config()
67
+
68
+ def _load_config(self):
69
+ if self.config_path.exists():
70
+ with open(self.config_path, "r") as f:
71
+ yaml_data = yaml.safe_load(f)
72
+ self.audio = AudioConfig.from_yaml(yaml_data.get("audio", {}))
73
+ self.selected_song = yaml_data.get(
74
+ "selected_song", "fly_me_to_the_moon"
75
+ )
76
+ self.chord_progressions = yaml_data.get("chord_progressions", {})
77
+ else:
78
+ self.audio = AudioConfig()
79
+ self.selected_song = "fly_me_to_the_moon"
80
+ self.chord_progressions = {
81
+ # opening 4 bars of Fly Me to the Moon
82
+ "fly_me_to_the_moon": [
83
+ ("A", "natural_minor", "A", "min7", 8),
84
+ ("A", "natural_minor", "D", "min7", 8),
85
+ ("C", "major", "G", "dom7", 8),
86
+ ("C", "major", "C", "maj7", 4),
87
+ ("F", "major", "C", "dom7", 4),
88
+ ]
89
+ }
improvisation_lab/domain/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Package containing domain logic."""
improvisation_lab/domain/analysis/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Module for music analysis."""
2
+
3
+ from improvisation_lab.domain.analysis.pitch_detector import PitchDetector
4
+
5
+ __all__ = ["PitchDetector"]
improvisation_lab/domain/analysis/pitch_detector.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PitchDetector class for real-time pitch detection using FCPE."""
2
+
3
+ import numpy as np
4
+ import torch
5
+ from torchfcpe import spawn_bundled_infer_model
6
+
7
+ from improvisation_lab.config import PitchDetectorConfig
8
+
9
+
10
+ class PitchDetector:
11
+ """Class for real-time pitch detection using FCPE."""
12
+
13
+ def __init__(self, config: PitchDetectorConfig):
14
+ """Initialize pitch detector.
15
+
16
+ Args:
17
+ config: Configuration settings for pitch detection.
18
+ """
19
+ self.sample_rate = config.sample_rate
20
+ self.hop_length = config.hop_length
21
+ self.decoder_mode = config.decoder_mode
22
+ self.threshold = config.threshold
23
+ self.f0_min = config.f0_min
24
+ self.f0_max = config.f0_max
25
+ self.interp_uv = config.interp_uv
26
+ self.model = spawn_bundled_infer_model(device=config.device)
27
+
28
+ def detect_pitch(self, audio_frame: np.ndarray) -> float:
29
+ """Detect pitch from audio frame.
30
+
31
+ Args:
32
+ audio_frame: Numpy array of audio samples
33
+
34
+ Returns:
35
+ Frequency in Hz
36
+ """
37
+ audio_length = len(audio_frame)
38
+ f0_target_length = (audio_length // self.hop_length) + 1
39
+
40
+ # Convert to torch tensor and reshape to match expected dimensions
41
+ # Add batch and channel dimensions
42
+ audio_tensor = torch.from_numpy(audio_frame).float()
43
+ audio_tensor = audio_tensor.unsqueeze(0).unsqueeze(-1)
44
+
45
+ pitch = self.model.infer(
46
+ audio_tensor,
47
+ sr=self.sample_rate,
48
+ decoder_mode=self.decoder_mode,
49
+ threshold=self.threshold,
50
+ f0_min=self.f0_min,
51
+ f0_max=self.f0_max,
52
+ interp_uv=self.interp_uv,
53
+ output_interp_target_length=f0_target_length,
54
+ )
55
+
56
+ # Extract the middle frequency value from the pitch tensor
57
+ # Taking the middle value helps avoid potential inaccuracies at the edges
58
+ # of the audio frame, providing a more stable frequency estimate.
59
+ middle_index = pitch.size(1) // 2
60
+ frequency = pitch[0, middle_index, 0].item()
61
+ return frequency
improvisation_lab/domain/composition/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Module for melody improvisation generation."""
2
+
3
+ from improvisation_lab.domain.composition.melody_composer import (
4
+ MelodyComposer, PhraseData)
5
+
6
+ __all__ = ["PhraseData", "MelodyComposer"]
improvisation_lab/domain/composition/melody_composer.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Module for handling melody generation and playback."""
2
+
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
12
+ class PhraseData:
13
+ """Data structure containing information about a melodic phrase."""
14
+
15
+ notes: List[str]
16
+ chord_name: str
17
+ scale_info: str
18
+ length: int
19
+
20
+
21
+ class MelodyComposer:
22
+ """Class responsible for generating melodic phrases based on chord progressions."""
23
+
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]]
30
+ ) -> List[PhraseData]:
31
+ """Generate a sequence of melodic phrases based on a chord progression.
32
+
33
+ Args:
34
+ progression:
35
+ List of tuples containing (scale_root, scale_type, chord_root,
36
+ chord_type, length) for each chord in the progression.
37
+
38
+ Returns:
39
+ List of PhraseData objects containing the generated melodic phrases.
40
+ """
41
+ phrases: List[PhraseData] = []
42
+ prev_note: Optional[str] = None
43
+ prev_note_was_chord_tone = False
44
+
45
+ for scale_root, scale_type, chord_root, chord_type, length in progression:
46
+ phrase = self.phrase_generator.generate_phrase(
47
+ scale_root=scale_root,
48
+ scale_type=scale_type,
49
+ chord_root=chord_root,
50
+ chord_type=chord_type,
51
+ prev_note=prev_note,
52
+ prev_note_was_chord_tone=prev_note_was_chord_tone,
53
+ length=length,
54
+ )
55
+
56
+ # Update information for the next phrase
57
+ prev_note = phrase[-1]
58
+ prev_note_was_chord_tone = self.phrase_generator.is_chord_tone(
59
+ prev_note, ChordTone.get_chord_tones(chord_root, chord_type)
60
+ )
61
+
62
+ phrases.append(
63
+ PhraseData(
64
+ notes=phrase,
65
+ chord_name=f"{chord_root}{chord_type}",
66
+ scale_info=f"{scale_root} {scale_type}",
67
+ length=length,
68
+ )
69
+ )
70
+
71
+ return phrases
improvisation_lab/domain/composition/phrase_generator.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Module for generating improvised melody phrases.
2
+
3
+ This module provides functionality to generate natural melody phrases
4
+ based on given scales and chord progressions, following music theory principles.
5
+ """
6
+
7
+ import random
8
+
9
+ from improvisation_lab.domain.music_theory import ChordTone, Notes, Scale
10
+
11
+
12
+ class PhraseGenerator:
13
+ """Class for generating improvised melody phrases.
14
+
15
+ This class generates melody phrases based on given scales and chord progressions,
16
+ following music theory rules.
17
+ The next note selection depends on whether the current note is a chord tone or not,
18
+ with chord tones having more freedom in movement
19
+ while non-chord tones move to adjacent notes.
20
+ """
21
+
22
+ def is_chord_tone(self, note: str, chord_tones: list[str]) -> bool:
23
+ """Check if a note is a chord tone.
24
+
25
+ Args:
26
+ note: The note to check.
27
+ chord_tones: The list of chord tones.
28
+
29
+ Returns:
30
+ True if the note is a chord tone, False otherwise.
31
+ """
32
+ return note in chord_tones
33
+
34
+ def get_adjacent_notes(self, note: str, scale_notes: list[str]) -> list[str]:
35
+ """Get adjacent notes to a given note.
36
+
37
+ Args:
38
+ note: The note to get adjacent notes to.
39
+ scale_notes: The list of notes in the scale.
40
+
41
+ Returns:
42
+ The list of adjacent notes in order (lower note first, then higher note).
43
+ """
44
+ length_scale_notes = len(scale_notes)
45
+ if note in scale_notes:
46
+ note_index = scale_notes.index(note)
47
+ return [
48
+ scale_notes[(note_index - 1) % length_scale_notes],
49
+ scale_notes[(note_index + 1) % length_scale_notes],
50
+ ]
51
+
52
+ return [
53
+ self._find_closest_note_in_direction(note, scale_notes, -1),
54
+ self._find_closest_note_in_direction(note, scale_notes, 1),
55
+ ]
56
+
57
+ def _find_closest_note_in_direction(
58
+ self, note: str, scale_notes: list[str], direction: int
59
+ ) -> str:
60
+ """Find the closest note in a given direction within the scale.
61
+
62
+ Args:
63
+ start_index: Starting index in the chromatic scale.
64
+ all_notes: List of all notes (chromatic scale).
65
+ scale_notes: List of notes in the target scale.
66
+ direction: Direction to search (-1 for lower, 1 for higher).
67
+
68
+ Returns:
69
+ The closest note in the given direction that exists in the scale.
70
+ """
71
+ all_notes = [note.value for note in Notes] # Chromatic scale
72
+ note_index = all_notes.index(note)
73
+
74
+ current_index = note_index
75
+ while True:
76
+ current_index = (current_index + direction) % 12
77
+ current_note = all_notes[current_index]
78
+ if current_note in scale_notes:
79
+ return current_note
80
+ if current_index == note_index: # If we've gone full circle
81
+ break
82
+ return all_notes[current_index]
83
+
84
+ def get_next_note(
85
+ self, current_note: str, scale_notes: list[str], chord_tones: list[str]
86
+ ) -> str:
87
+ """Get the next note based on the current note, scale, and chord tones.
88
+
89
+ Args:
90
+ current_note: The current note.
91
+ scale_notes: The list of notes in the scale.
92
+ chord_tones: The list of chord tones.
93
+
94
+ Returns:
95
+ The next note.
96
+ """
97
+ is_current_chord_tone = self.is_chord_tone(current_note, chord_tones)
98
+
99
+ if is_current_chord_tone:
100
+ # For chord tones, freely move to any scale note
101
+ available_notes = [note for note in scale_notes if note != current_note]
102
+ return random.choice(available_notes)
103
+ # For non-chord tones, move to adjacent notes only
104
+ adjacent_notes = self.get_adjacent_notes(current_note, scale_notes)
105
+ return random.choice(adjacent_notes)
106
+
107
+ def select_first_note(
108
+ self,
109
+ scale_notes: list[str],
110
+ chord_tones: list[str],
111
+ prev_note: str | None = None,
112
+ prev_note_was_chord_tone: bool = False,
113
+ ) -> str:
114
+ """Select the first note of a phrase.
115
+
116
+ Args:
117
+ scale_notes: The list of notes in the scale.
118
+ chord_tones: The list of chord tones.
119
+ prev_note: The last note of the previous phrase (default: None).
120
+ prev_note_was_chord_tone:
121
+ Whether the previous note was a chord tone (default: False).
122
+
123
+ Returns:
124
+ The selected first note.
125
+ """
126
+ # For the first phrase, randomly select from scale notes
127
+ if prev_note is None:
128
+ return random.choice(scale_notes)
129
+
130
+ # Case: previous note was a chord tone, can move freely
131
+ if prev_note_was_chord_tone:
132
+ available_notes = [note for note in scale_notes if note != prev_note]
133
+ return random.choice(available_notes)
134
+
135
+ # Case: previous note was not a chord tone
136
+ if prev_note in chord_tones:
137
+ # If it's a chord tone in the current chord, can move freely
138
+ available_notes = [note for note in scale_notes if note != prev_note]
139
+ return random.choice(available_notes)
140
+
141
+ # If it's not a chord tone, can only move to adjacent notes
142
+ adjacent_notes = self.get_adjacent_notes(prev_note, scale_notes)
143
+ return random.choice(adjacent_notes)
144
+
145
+ def generate_phrase(
146
+ self,
147
+ scale_root: str,
148
+ scale_type: str,
149
+ chord_root: str,
150
+ chord_type: str,
151
+ prev_note: str | None = None,
152
+ prev_note_was_chord_tone: bool = False,
153
+ length=8,
154
+ ) -> list[str]:
155
+ """Generate a phrase of notes.
156
+
157
+ Args:
158
+ scale_root: The root note of the scale.
159
+ scale_type: The type of scale (e.g., "major", "natural_minor").
160
+ chord_root: The root note of the chord.
161
+ chord_type: The type of chord (e.g., "maj", "maj7").
162
+ prev_note: The last note of the previous phrase (default: None).
163
+ prev_note_was_chord_tone:
164
+ Whether the previous note was a chord tone (default: False).
165
+ length: The length of the phrase (default: 8).
166
+
167
+ Returns:
168
+ A list of note names in the phrase.
169
+ """
170
+ # Get scale notes and chord tones
171
+ scale_notes = Scale.get_scale_notes(scale_root, scale_type)
172
+ chord_tones = ChordTone.get_chord_tones(chord_root, chord_type)
173
+
174
+ # Generate the phrase
175
+ phrase = []
176
+
177
+ # Select the first note
178
+ current_note = self.select_first_note(
179
+ scale_notes, chord_tones, prev_note, prev_note_was_chord_tone
180
+ )
181
+ phrase.append(current_note)
182
+
183
+ # Generate remaining notes
184
+ for _ in range(length - 1):
185
+ current_note = self.get_next_note(current_note, scale_notes, chord_tones)
186
+ phrase.append(current_note)
187
+
188
+ return phrase
improvisation_lab/domain/music_theory.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Module containing basic music theory concepts and constants."""
2
+
3
+ from enum import Enum
4
+
5
+ import numpy as np
6
+
7
+
8
+ class Notes(str, Enum):
9
+ """Enumeration of musical notes in chromatic scale.
10
+
11
+ This class represents the twelve notes of the chromatic scale
12
+ and provides methods for note manipulation and validation.
13
+ It inherits from both str and Enum to provide string-like behavior
14
+ while maintaining the benefits of enumeration.
15
+
16
+ The str inheritance allows direct string operations on the note values,
17
+ while Enum ensures type safety and provides a defined set of valid notes.
18
+
19
+ Examples:
20
+ >>> note = Notes.C
21
+ >>> isinstance(note, str) # True
22
+ >>> note.lower() # 'c'
23
+ >>> note + 'm' # 'Cm'
24
+ """
25
+
26
+ C = "C"
27
+ C_SHARP = "C#"
28
+ D = "D"
29
+ D_SHARP = "D#"
30
+ E = "E"
31
+ F = "F"
32
+ F_SHARP = "F#"
33
+ G = "G"
34
+ G_SHARP = "G#"
35
+ A = "A"
36
+ A_SHARP = "A#"
37
+ B = "B"
38
+
39
+ @classmethod
40
+ def get_note_index(cls, note: str) -> int:
41
+ """Get the index of a note in the chromatic scale.
42
+
43
+ Args:
44
+ note (str): The note name to find the index for.
45
+
46
+ Returns:
47
+ int: The index of the note in the chromatic scale (0-11).
48
+ """
49
+ return list(cls).index(cls(note))
50
+
51
+ @classmethod
52
+ def get_chromatic_scale(cls, note: str) -> list[str]:
53
+ """Return all notes in chromatic order.
54
+
55
+ Args:
56
+ note (str): The note name to start the chromatic scale from.
57
+
58
+ Returns:
59
+ list[str]: A list of note names in chromatic order,
60
+ starting from C (e.g., ["C", "C#", "D", ...]).
61
+ """
62
+ start_idx = cls.get_note_index(note)
63
+ all_notes = [note.value for note in cls]
64
+ return all_notes[start_idx:] + all_notes[:start_idx]
65
+
66
+ @classmethod
67
+ def convert_frequency_to_note(cls, frequency: float) -> str:
68
+ """Convert a frequency in Hz to the nearest note name on a piano keyboard.
69
+
70
+ Args:
71
+ frequency: The frequency in Hz.
72
+
73
+ Returns:
74
+ The name of the nearest note.
75
+ """
76
+ A4_frequency = 440.0
77
+ # Calculate the number of semitones from A4 (440Hz)
78
+ n = 12 * np.log2(frequency / A4_frequency)
79
+
80
+ # Round to the nearest semitone
81
+ n = round(n)
82
+
83
+ # Calculate octave and index of note name with respect to A4
84
+ octave = 4 + (n + 9) // 12
85
+ note_idx = (n + 9) % 12
86
+
87
+ note = cls.get_chromatic_scale(cls.C)[note_idx]
88
+ return f"{note}{octave}"
89
+
90
+ @classmethod
91
+ def convert_frequency_to_base_note(cls, frequency: float) -> str:
92
+ """Convert frequency to base note name without octave number.
93
+
94
+ Args:
95
+ frequency: Frequency in Hz
96
+
97
+ Returns:
98
+ Base note name (e.g., 'C', 'C#', 'D')
99
+ """
100
+ note_with_octave = cls.convert_frequency_to_note(frequency)
101
+ return note_with_octave[:-1] # Remove the octave number
102
+
103
+
104
+ class Scale:
105
+ """Musical scale representation and operations.
106
+
107
+ This class handles scale-related operations including scale generation
108
+ and scale note calculations.
109
+ """
110
+
111
+ SCALES = {
112
+ "major": [0, 2, 4, 5, 7, 9, 11],
113
+ "natural_minor": [0, 2, 3, 5, 7, 8, 10],
114
+ "harmonic_minor": [0, 2, 3, 5, 7, 8, 11],
115
+ "diminished": [0, 2, 3, 5, 6, 8, 9, 11],
116
+ }
117
+
118
+ @classmethod
119
+ def get_scale_notes(cls, root_note: str, scale_type: str) -> list[str]:
120
+ """Generate scale notes from root note and scale type.
121
+
122
+ Args:
123
+ root_note: The root note of the scale.
124
+ scale_type: The type of scale (e.g., "major", "natural_minor").
125
+
126
+ Returns:
127
+ A list of note names in the scale.
128
+
129
+ Raises:
130
+ ValueError: If root_note is invalid or scale_type is not recognized.
131
+ """
132
+ if scale_type not in cls.SCALES:
133
+ raise ValueError(f"Invalid scale type: {scale_type}")
134
+
135
+ scale_pattern = cls.SCALES[scale_type]
136
+ chromatic = Notes.get_chromatic_scale(root_note)
137
+ return [chromatic[interval % 12] for interval in scale_pattern]
138
+
139
+
140
+ class ChordTone:
141
+ """Musical chord tone representation and operations.
142
+
143
+ This class handles chord tone-related operations
144
+ including chord tone generation and chord tone calculation.
145
+ """
146
+
147
+ CHORD_TONES = {
148
+ "maj": [0, 4, 7, 9],
149
+ "maj7": [0, 4, 7, 11],
150
+ "min7": [0, 3, 7, 10],
151
+ "min7(b5)": [0, 3, 6, 10],
152
+ "dom7": [0, 4, 7, 10],
153
+ "dim7": [0, 3, 6, 9],
154
+ }
155
+
156
+ @classmethod
157
+ def get_chord_tones(cls, root_note: str, chord_type: str) -> list[str]:
158
+ """Generate chord tones from root note and chord type.
159
+
160
+ Args:
161
+ root_note: The root note of the chord.
162
+ chord_type: The type of chord (e.g., "maj", "maj7").
163
+
164
+ Returns:
165
+ A list of note names in the chord.
166
+ """
167
+ if chord_type not in cls.CHORD_TONES:
168
+ raise ValueError(f"Invalid chord type: {chord_type}")
169
+
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]
improvisation_lab/infrastructure/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Infrastructure layer for handling external dependencies and implementations."""
improvisation_lab/infrastructure/audio/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio infrastructure components."""
2
+
3
+ from improvisation_lab.infrastructure.audio.audio_processor import \
4
+ AudioProcessor
5
+ from improvisation_lab.infrastructure.audio.direct_processor import \
6
+ DirectAudioProcessor
7
+ from improvisation_lab.infrastructure.audio.web_processor import \
8
+ WebAudioProcessor
9
+
10
+ __all__ = ["AudioProcessor", "DirectAudioProcessor", "WebAudioProcessor"]
improvisation_lab/infrastructure/audio/audio_processor.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Module providing abstract base class for audio input handling."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Callable
5
+
6
+ import numpy as np
7
+
8
+
9
+ class AudioProcessor(ABC):
10
+ """Abstract base class for audio input handling."""
11
+
12
+ def __init__(
13
+ self,
14
+ sample_rate: int,
15
+ callback: Callable[[np.ndarray], None] | None = None,
16
+ buffer_duration: float = 0.2,
17
+ ):
18
+ """Initialize AudioInput.
19
+
20
+ Args:
21
+ sample_rate: Audio sample rate in Hz
22
+ callback: Optional callback function to process audio data
23
+ buffer_duration: Duration of audio buffer in seconds
24
+ """
25
+ self.sample_rate = sample_rate
26
+ self.is_recording = False
27
+ self._callback = callback
28
+ self._buffer = np.array([], dtype=np.float32)
29
+ self._buffer_size = int(sample_rate * buffer_duration)
30
+
31
+ def _append_to_buffer(self, audio_data: np.ndarray) -> None:
32
+ """Append new audio data to the buffer."""
33
+ # Convert stereo to mono if necessary
34
+ if audio_data.ndim > 1:
35
+ audio_data = np.mean(audio_data, axis=1)
36
+ self._buffer = np.concatenate([self._buffer, audio_data])
37
+
38
+ def _process_buffer(self) -> None:
39
+ """Process buffer data if it has reached the desired size."""
40
+ if len(self._buffer) >= self._buffer_size:
41
+ if self._callback is not None:
42
+ self._callback(self._buffer[: self._buffer_size])
43
+ self._buffer = self._buffer[self._buffer_size :]
44
+
45
+ @abstractmethod
46
+ def start_recording(self):
47
+ """Start recording audio."""
48
+ pass
49
+
50
+ @abstractmethod
51
+ def stop_recording(self):
52
+ """Stop recording audio."""
53
+ pass
improvisation_lab/infrastructure/audio/direct_processor.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Module for handling microphone input and audio processing.
2
+
3
+ This module provides functionality for real-time audio capture from a microphone,
4
+ with support for buffering and callback-based processing of audio data.
5
+ """
6
+
7
+ from typing import Callable
8
+
9
+ import numpy as np
10
+ import pyaudio
11
+
12
+ from improvisation_lab.infrastructure.audio.audio_processor import \
13
+ AudioProcessor
14
+
15
+
16
+ class DirectAudioProcessor(AudioProcessor):
17
+ """Handle real-time audio input from microphone.
18
+
19
+ This class provides functionality to:
20
+ 1. Capture audio from the default microphone
21
+ 2. Buffer the incoming audio data
22
+ 3. Process the buffered data through a user-provided callback function
23
+
24
+ The audio processing is done in chunks, with the chunk size determined by
25
+ the buffer_duration parameter. This allows for efficient real-time
26
+ processing of audio data, such as pitch detection.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ sample_rate: int,
32
+ callback: Callable[[np.ndarray], None] | None = None,
33
+ buffer_duration: float = 0.2,
34
+ ):
35
+ """Initialize MicInput.
36
+
37
+ Args:
38
+ sample_rate: Audio sample rate in Hz
39
+ callback: Optional callback function to process audio data
40
+ buffer_duration: Duration of audio buffer in seconds before processing
41
+ """
42
+ super().__init__(sample_rate, callback, buffer_duration)
43
+ self.audio = None
44
+ self._stream = None
45
+
46
+ def _audio_callback(
47
+ self, in_data: bytes, frame_count: int, time_info: dict, status: int
48
+ ) -> tuple[bytes, int]:
49
+ """Process incoming audio data.
50
+
51
+ This callback is automatically called by PyAudio
52
+ when new audio data is available.
53
+ The audio data is converted to a numpy array and:
54
+ 1. Stored in the internal buffer
55
+ 2. Passed to the user-provided callback function if one exists
56
+
57
+ Note:
58
+ This method follows PyAudio's callback function specification.
59
+ It must accept four arguments (in_data, frame_count, time_info, status)
60
+ and return a tuple of (bytes, status_flag).
61
+ These arguments are automatically provided by PyAudio
62
+ when calling this callback.
63
+
64
+ Args:
65
+ in_data: Raw audio input data as bytes
66
+ frame_count: Number of frames in the input
67
+ time_info: Dictionary with timing information
68
+ status: Stream status flag
69
+
70
+ Returns:
71
+ Tuple of (input_data, pyaudio.paContinue)
72
+ """
73
+ # Convert bytes to numpy array (float32 format)
74
+ audio_data = np.frombuffer(in_data, dtype=np.float32)
75
+ self._append_to_buffer(audio_data)
76
+ self._process_buffer()
77
+ return (in_data, pyaudio.paContinue)
78
+
79
+ def start_recording(self):
80
+ """Start recording from microphone."""
81
+ if self.is_recording:
82
+ raise RuntimeError("Recording is already in progress")
83
+
84
+ self.audio = pyaudio.PyAudio()
85
+ self._stream = self.audio.open(
86
+ format=pyaudio.paFloat32,
87
+ channels=1,
88
+ rate=self.sample_rate,
89
+ input=True,
90
+ stream_callback=self._audio_callback,
91
+ )
92
+ self.is_recording = True
93
+
94
+ def stop_recording(self):
95
+ """Stop recording from microphone."""
96
+ if not self.is_recording:
97
+ raise RuntimeError("Recording is not in progress")
98
+
99
+ self._stream.stop_stream()
100
+ self._stream.close()
101
+ self.audio.terminate()
102
+ self.is_recording = False
103
+ self._stream = None
104
+ self.audio = None
improvisation_lab/infrastructure/audio/web_processor.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Module for handling audio input through Gradio interface."""
2
+
3
+ from typing import Callable
4
+
5
+ import numpy as np
6
+ from scipy import signal
7
+
8
+ from improvisation_lab.infrastructure.audio.audio_processor import \
9
+ AudioProcessor
10
+
11
+
12
+ class WebAudioProcessor(AudioProcessor):
13
+ """Handle audio input from Gradio interface."""
14
+
15
+ def __init__(
16
+ self,
17
+ sample_rate: int,
18
+ callback: Callable[[np.ndarray], None] | None = None,
19
+ buffer_duration: float = 0.2,
20
+ ):
21
+ """Initialize GradioAudioInput.
22
+
23
+ Args:
24
+ sample_rate: Audio sample rate in Hz
25
+ callback: Optional callback function to process audio data
26
+ buffer_duration: Duration of audio buffer in seconds
27
+ """
28
+ super().__init__(sample_rate, callback, buffer_duration)
29
+
30
+ def _resample_audio(
31
+ self, audio_data: np.ndarray, original_sr: int, target_sr: int
32
+ ) -> np.ndarray:
33
+ """Resample audio data to target sample rate.
34
+
35
+ In the case of Gradio,
36
+ the sample rate of the audio data may not match the target sample rate.
37
+
38
+ Args:
39
+ audio_data: numpy array of audio samples
40
+ original_sr: Original sample rate in Hz
41
+ target_sr: Target sample rate in Hz
42
+
43
+ Returns:
44
+ Resampled audio data with target sample rate
45
+ """
46
+ number_of_samples = round(len(audio_data) * float(target_sr) / original_sr)
47
+ resampled_data = signal.resample(audio_data, number_of_samples)
48
+ return resampled_data
49
+
50
+ def _normalize_audio(self, audio_data: np.ndarray) -> np.ndarray:
51
+ """Normalize audio data to range [-1, 1] by dividing by maximum absolute value.
52
+
53
+ Args:
54
+ audio_data: numpy array of audio samples
55
+
56
+ Returns:
57
+ Normalized audio data with values between -1 and 1
58
+ """
59
+ if len(audio_data) == 0:
60
+ return audio_data
61
+ max_abs = np.max(np.abs(audio_data))
62
+ return audio_data if max_abs == 0 else audio_data / max_abs
63
+
64
+ def _remove_low_amplitude_noise(self, audio_data: np.ndarray) -> np.ndarray:
65
+ """Remove low amplitude noise from audio data.
66
+
67
+ Applies a threshold to remove low amplitude signals that are likely noise.
68
+
69
+ Args:
70
+ audio_data: Audio data as numpy array
71
+
72
+ Returns:
73
+ Audio data with low amplitude noise removed
74
+ """
75
+ # [TODO] Set appropriate threshold
76
+ threshold = 20.0
77
+ audio_data[np.abs(audio_data) < threshold] = 0
78
+ return audio_data
79
+
80
+ def process_audio(self, audio_input: tuple[int, np.ndarray]) -> None:
81
+ """Process incoming audio data from Gradio.
82
+
83
+ Args:
84
+ audio_input: Tuple of (sample_rate, audio_data)
85
+ where audio_data is a (samples, channels) array
86
+ """
87
+ if not self.is_recording:
88
+ return
89
+
90
+ input_sample_rate, audio_data = audio_input
91
+ if input_sample_rate != self.sample_rate:
92
+ audio_data = self._resample_audio(
93
+ audio_data, input_sample_rate, self.sample_rate
94
+ )
95
+ audio_data = self._remove_low_amplitude_noise(audio_data)
96
+ audio_data = self._normalize_audio(audio_data)
97
+
98
+ self._append_to_buffer(audio_data)
99
+ self._process_buffer()
100
+
101
+ def start_recording(self):
102
+ """Start accepting audio input from Gradio."""
103
+ if self.is_recording:
104
+ raise RuntimeError("Recording is already in progress")
105
+ self.is_recording = True
106
+
107
+ def stop_recording(self):
108
+ """Stop accepting audio input from Gradio."""
109
+ if not self.is_recording:
110
+ raise RuntimeError("Recording is not in progress")
111
+ self.is_recording = False
112
+ self._buffer = np.array([], dtype=np.float32)
improvisation_lab/presentation/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Presentation layer for the application."""
improvisation_lab/presentation/melody_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.melody_practice.console_melody_view import \
8
+ ConsoleMelodyView
9
+ from improvisation_lab.presentation.melody_practice.view_text_manager import \
10
+ ViewTextManager
11
+ from improvisation_lab.presentation.melody_practice.web_melody_view import \
12
+ WebMelodyView
13
+
14
+ __all__ = ["WebMelodyView", "ViewTextManager", "ConsoleMelodyView"]
improvisation_lab/presentation/melody_practice/console_melody_view.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Console-based melody practice view.
2
+
3
+ This module provides a console interface for visualizing
4
+ and interacting with melody practice sessions.
5
+ """
6
+
7
+ from typing import List
8
+
9
+ from improvisation_lab.domain.composition import PhraseData
10
+ from improvisation_lab.presentation.melody_practice.view_text_manager import \
11
+ ViewTextManager
12
+ from improvisation_lab.service.melody_practice_service import PitchResult
13
+
14
+
15
+ class ConsoleMelodyView:
16
+ """Console-based implementation of melody visualization."""
17
+
18
+ def __init__(self, text_manager: ViewTextManager, song_name: str):
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
+ self.song_name = song_name
27
+
28
+ def launch(self):
29
+ """Run the console interface."""
30
+ print("\n" + f"Generating melody for {self.song_name}:")
31
+ print("Sing each note for 1 second!")
32
+
33
+ def display_phrase_info(self, phrase_number: int, phrases_data: List[PhraseData]):
34
+ """Display phrase information in console.
35
+
36
+ Args:
37
+ phrase_number: Number of the phrase.
38
+ phrases_data: List of phrase data.
39
+ """
40
+ self.text_manager.update_phrase_text(phrase_number, phrases_data)
41
+ print("\n" + "-" * 50)
42
+ print("\n" + self.text_manager.phrase_text + "\n")
43
+
44
+ def display_pitch_result(self, pitch_result: PitchResult):
45
+ """Display note status in console.
46
+
47
+ Args:
48
+ pitch_result: The result of the pitch detection.
49
+ """
50
+ self.text_manager.update_pitch_result(pitch_result)
51
+ print(f"{self.text_manager.result_text:<80}", end="\r", flush=True)
52
+
53
+ def display_practice_end(self):
54
+ """Display practice end message in console."""
55
+ self.text_manager.terminate_text()
56
+ print(self.text_manager.phrase_text)
improvisation_lab/presentation/melody_practice/view_text_manager.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
8
+
9
+ from improvisation_lab.domain.composition import PhraseData
10
+ from improvisation_lab.service.melody_practice_service import PitchResult
11
+
12
+
13
+ class ViewTextManager:
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
+ def update_phrase_text(self, current_phrase_idx: int, phrases: List[PhraseData]):
49
+ """Update the phrase text.
50
+
51
+ Args:
52
+ current_phrase_idx: The index of the current phrase.
53
+ phrases: The list of phrases.
54
+ """
55
+ if not phrases:
56
+ self.phrase_text = "No phrase data"
57
+ return self.phrase_text
58
+
59
+ current_phrase = phrases[current_phrase_idx]
60
+ self.phrase_text = (
61
+ f"Phrase {current_phrase_idx + 1}: "
62
+ f"{current_phrase.chord_name}\n"
63
+ f"{' -> '.join(current_phrase.notes)}"
64
+ )
65
+
66
+ if current_phrase_idx < len(phrases) - 1:
67
+ next_phrase = phrases[current_phrase_idx + 1]
68
+ self.phrase_text += (
69
+ f"\nNext: {next_phrase.chord_name} ({next_phrase.notes[0]})"
70
+ )
improvisation_lab/presentation/melody_practice/web_melody_view.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Web-based melody practice view.
2
+
3
+ This module provides a web interface using Gradio for visualizing
4
+ and interacting with melody practice sessions.
5
+ """
6
+
7
+ from typing import Any, Callable
8
+
9
+ import gradio as gr
10
+
11
+
12
+ class WebMelodyView:
13
+ """Handles the user interface for the melody practice application."""
14
+
15
+ def __init__(
16
+ self,
17
+ on_generate_melody: Callable[[], tuple[str, str]],
18
+ on_end_practice: Callable[[], tuple[str, str]],
19
+ on_audio_input: Callable[[Any], tuple[str, str]],
20
+ song_name: str,
21
+ ):
22
+ """Initialize the UI with callback functions.
23
+
24
+ Args:
25
+ on_generate_melody: Function to call when start button is clicked
26
+ on_end_practice: Function to call when stop button is clicked
27
+ on_audio_input: Function to process audio input
28
+ song_name: Name of the song to be practiced
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
+ self.song_name = song_name
34
+
35
+ def _build_interface(self) -> gr.Blocks:
36
+ """Create and configure the Gradio interface.
37
+
38
+ Returns:
39
+ gr.Blocks: The Gradio interface.
40
+ """
41
+ with gr.Blocks() as app:
42
+ self._add_header()
43
+ self.generate_melody_button = gr.Button("Generate Melody")
44
+ with gr.Row():
45
+ self.phrase_info_box = gr.Textbox(label="Phrase Information", value="")
46
+ self.pitch_result_box = gr.Textbox(label="Pitch Result", value="")
47
+ self._add_audio_input()
48
+ self.end_practice_button = gr.Button("End Practice")
49
+
50
+ self._add_buttons_callbacks()
51
+
52
+ return app
53
+
54
+ def _add_header(self):
55
+ """Create the header section of the UI."""
56
+ gr.Markdown(f"# {self.song_name} Melody Practice\nSing each note for 1 second!")
57
+
58
+ def _add_buttons_callbacks(self):
59
+ """Create the control buttons section."""
60
+ # Connect button callbacks
61
+ self.generate_melody_button.click(
62
+ fn=self.on_generate_melody,
63
+ outputs=[self.phrase_info_box, self.pitch_result_box],
64
+ )
65
+
66
+ self.end_practice_button.click(
67
+ fn=self.on_end_practice,
68
+ outputs=[self.phrase_info_box, self.pitch_result_box],
69
+ )
70
+
71
+ def _add_audio_input(self):
72
+ """Create the audio input section."""
73
+ audio_input = gr.Audio(
74
+ label="Audio Input",
75
+ sources=["microphone"],
76
+ streaming=True,
77
+ type="numpy",
78
+ show_label=True,
79
+ )
80
+
81
+ # Attention: have to specify inputs explicitly,
82
+ # otherwise the callback function is not called
83
+ audio_input.stream(
84
+ fn=self.on_audio_input,
85
+ inputs=audio_input,
86
+ outputs=[self.phrase_info_box, self.pitch_result_box],
87
+ show_progress=False,
88
+ stream_every=0.1,
89
+ )
90
+
91
+ def launch(self, **kwargs):
92
+ """Launch the Gradio application.
93
+
94
+ Args:
95
+ **kwargs: Additional keyword arguments for the launch method.
96
+ """
97
+ app = self._build_interface()
98
+ app.queue()
99
+ app.launch(**kwargs)
improvisation_lab/service/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Service layer for the Improvisation Lab."""
2
+
3
+ from improvisation_lab.service.melody_practice_service import \
4
+ MelodyPracticeService
5
+
6
+ __all__ = ["MelodyPracticeService"]
improvisation_lab/service/melody_practice_service.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Service for practicing melodies."""
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+
6
+ import numpy as np
7
+
8
+ from improvisation_lab.config import Config
9
+ from improvisation_lab.domain.analysis import PitchDetector
10
+ from improvisation_lab.domain.composition import MelodyComposer, PhraseData
11
+ from improvisation_lab.domain.music_theory import Notes
12
+
13
+
14
+ @dataclass
15
+ class PitchResult:
16
+ """Result of pitch detection."""
17
+
18
+ target_note: str
19
+ current_base_note: str | None
20
+ is_correct: bool
21
+ remaining_time: float
22
+
23
+
24
+ class MelodyPracticeService:
25
+ """Service for generating and processing melodies."""
26
+
27
+ def __init__(self, config: Config):
28
+ """Initialize MelodyPracticeService with configuration."""
29
+ self.config = config
30
+ self.melody_composer = MelodyComposer()
31
+ self.pitch_detector = PitchDetector(config.audio.pitch_detector)
32
+
33
+ self.correct_pitch_start_time: float | None = None
34
+
35
+ def generate_melody(self) -> list[PhraseData]:
36
+ """Generate a melody based on the configured chord progression.
37
+
38
+ Returns:
39
+ List of PhraseData instances representing the generated melody.
40
+ """
41
+ selected_progression = self.config.chord_progressions[self.config.selected_song]
42
+ return self.melody_composer.generate_phrases(selected_progression)
43
+
44
+ def process_audio(self, audio_data: np.ndarray, target_note: str) -> PitchResult:
45
+ """Process audio data to detect pitch and provide feedback.
46
+
47
+ Args:
48
+ audio_data: Audio data as a numpy array.
49
+ target_note: The target note to display.
50
+ Returns:
51
+ PitchResult containing the target note, detected note, correctness,
52
+ and remaining time.
53
+ """
54
+ frequency = self.pitch_detector.detect_pitch(audio_data)
55
+
56
+ if frequency <= 0: # if no voice detected, reset the correct pitch start time
57
+ return self._create_no_voice_result(target_note)
58
+
59
+ note_name = Notes.convert_frequency_to_base_note(frequency)
60
+ if note_name != target_note:
61
+ return self._create_incorrect_pitch_result(target_note, note_name)
62
+
63
+ return self._create_correct_pitch_result(target_note, note_name)
64
+
65
+ def _create_no_voice_result(self, target_note: str) -> PitchResult:
66
+ """Create result for no voice detected case.
67
+
68
+ Args:
69
+ target_note: The target note to display.
70
+
71
+ Returns:
72
+ PitchResult for no voice detected case.
73
+ """
74
+ self.correct_pitch_start_time = None
75
+ return PitchResult(
76
+ target_note=target_note,
77
+ current_base_note=None,
78
+ is_correct=False,
79
+ remaining_time=self.config.audio.note_duration,
80
+ )
81
+
82
+ def _create_incorrect_pitch_result(
83
+ self, target_note: str, detected_note: str
84
+ ) -> PitchResult:
85
+ """Create result for incorrect pitch case, reset the correct pitch start time.
86
+
87
+ Args:
88
+ target_note: The target note to display.
89
+ detected_note: The detected note.
90
+
91
+ Returns:
92
+ PitchResult for incorrect pitch case.
93
+ """
94
+ self.correct_pitch_start_time = None
95
+ return PitchResult(
96
+ target_note=target_note,
97
+ current_base_note=detected_note,
98
+ is_correct=False,
99
+ remaining_time=self.config.audio.note_duration,
100
+ )
101
+
102
+ def _create_correct_pitch_result(
103
+ self, target_note: str, detected_note: str
104
+ ) -> PitchResult:
105
+ """Create result for correct pitch case.
106
+
107
+ Args:
108
+ target_note: The target note to display.
109
+ detected_note: The detected note.
110
+
111
+ Returns:
112
+ PitchResult for correct pitch case.
113
+ """
114
+ current_time = time.time()
115
+ # Note is completed if the correct pitch is sustained for the duration of a note
116
+ if self.correct_pitch_start_time is None:
117
+ self.correct_pitch_start_time = current_time
118
+ remaining_time = self.config.audio.note_duration
119
+ else:
120
+ elapsed_time = current_time - self.correct_pitch_start_time
121
+ remaining_time = max(0, self.config.audio.note_duration - elapsed_time)
122
+
123
+ return PitchResult(
124
+ target_note=target_note,
125
+ current_base_note=detected_note,
126
+ is_correct=True,
127
+ remaining_time=remaining_time,
128
+ )
main.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Main application module for melody practice.
2
+
3
+ This module initializes and launches the melody practice application
4
+ using either a web or console interface.
5
+ """
6
+
7
+ import argparse
8
+
9
+ from improvisation_lab.application.melody_practice import \
10
+ MelodyPracticeAppFactory
11
+ from improvisation_lab.config import Config
12
+ from improvisation_lab.service import MelodyPracticeService
13
+
14
+
15
+ def main():
16
+ """Run the application."""
17
+ parser = argparse.ArgumentParser(description="Run the melody practice application")
18
+ parser.add_argument(
19
+ "--app_type",
20
+ choices=["web", "console"],
21
+ default="web",
22
+ help="Type of application to run (web or console)",
23
+ )
24
+ args = parser.parse_args()
25
+
26
+ config = Config()
27
+ service = MelodyPracticeService(config)
28
+ app = MelodyPracticeAppFactory.create_app(args.app_type, service, config)
29
+ app.launch()
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ portaudio19-dev
poetry.lock ADDED
The diff for this file is too large to render. See raw diff
 
pyproject.toml ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [tool.poetry]
2
+ name = "improvisation-lab"
3
+ version = "0.2.0"
4
+ description = ""
5
+ authors = ["atsushieee <atsushi.tabata1204@gmail.com>"]
6
+ readme = "README.md"
7
+ packages = [
8
+ {include = "improvisation_lab"},
9
+ {include = "scripts"}
10
+ ]
11
+
12
+ [tool.poetry.dependencies]
13
+ python = "^3.11"
14
+ torch = "2.2.2"
15
+ torchfcpe = "^0.0.4"
16
+ numpy = "1.26.4"
17
+ pyaudio = "^0.2.14"
18
+ pyyaml = "^6.0.2"
19
+ types-pyyaml = "^6.0.12.20240917"
20
+ scipy = "^1.14.1"
21
+ gradio = "5.7.1"
22
+
23
+
24
+ [tool.poetry.group.dev.dependencies]
25
+ mypy = "^1.13.0"
26
+ black = "^24.10.0"
27
+ isort = "^5.13.2"
28
+ pydocstyle = "^6.3.0"
29
+ pytest = "^8.3.3"
30
+ pyproject-flake8 = "^7.0.0"
31
+ pytest-mock = "^3.14.0"
32
+
33
+ [tool.flake8]
34
+ max-line-length = 88
35
+ extend-ignore = "E203"
36
+
37
+ [tool.black]
38
+ line-length = 88
39
+
40
+ [tool.mypy]
41
+ ignore_missing_imports = "True"
42
+
43
+ [build-system]
44
+ requires = ["poetry-core"]
45
+ build-backend = "poetry.core.masonry.api"
requirements.txt ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ aiofiles==23.2.1 ; python_version >= "3.11" and python_version < "4.0"
2
+ annotated-types==0.7.0 ; python_version >= "3.11" and python_version < "4.0"
3
+ anyio==4.6.2.post1 ; python_version >= "3.11" and python_version < "4.0"
4
+ audioop-lts==0.2.1 ; python_version >= "3.13" and python_version < "4.0"
5
+ certifi==2024.8.30 ; python_version >= "3.11" and python_version < "4.0"
6
+ charset-normalizer==3.4.0 ; python_version >= "3.11" and python_version < "4.0"
7
+ click==8.1.7 ; python_version >= "3.11" and python_version < "4.0" and sys_platform != "emscripten"
8
+ colorama==0.4.6 ; python_version >= "3.11" and python_version < "4.0" and platform_system == "Windows"
9
+ einops==0.8.0 ; python_version >= "3.11" and python_version < "4.0"
10
+ fastapi==0.115.6 ; python_version >= "3.11" and python_version < "4.0"
11
+ ffmpy==0.4.0 ; python_version >= "3.11" and python_version < "4.0"
12
+ filelock==3.16.1 ; python_version >= "3.11" and python_version < "4.0"
13
+ fsspec==2024.10.0 ; python_version >= "3.11" and python_version < "4.0"
14
+ gradio-client==1.5.0 ; python_version >= "3.11" and python_version < "4.0"
15
+ gradio==5.7.1 ; python_version >= "3.11" and python_version < "4.0"
16
+ h11==0.14.0 ; python_version >= "3.11" and python_version < "4.0"
17
+ httpcore==1.0.7 ; python_version >= "3.11" and python_version < "4.0"
18
+ httpx==0.28.0 ; python_version >= "3.11" and python_version < "4.0"
19
+ huggingface-hub==0.26.3 ; python_version >= "3.11" and python_version < "4.0"
20
+ idna==3.10 ; python_version >= "3.11" and python_version < "4.0"
21
+ jinja2==3.1.4 ; python_version >= "3.11" and python_version < "4.0"
22
+ local-attention==1.9.15 ; python_version >= "3.11" and python_version < "4.0"
23
+ markdown-it-py==3.0.0 ; python_version >= "3.11" and python_version < "4.0" and sys_platform != "emscripten"
24
+ markupsafe==2.1.5 ; python_version >= "3.11" and python_version < "4.0"
25
+ mdurl==0.1.2 ; python_version >= "3.11" and python_version < "4.0" and sys_platform != "emscripten"
26
+ mpmath==1.3.0 ; python_version >= "3.11" and python_version < "4.0"
27
+ networkx==3.4.2 ; python_version >= "3.11" and python_version < "4.0"
28
+ numpy==1.26.4 ; python_version >= "3.11" and python_version < "4.0"
29
+ nvidia-cublas-cu12==12.1.3.1 ; platform_system == "Linux" and platform_machine == "x86_64" and python_version >= "3.11" and python_version < "4.0"
30
+ nvidia-cuda-cupti-cu12==12.1.105 ; platform_system == "Linux" and platform_machine == "x86_64" and python_version >= "3.11" and python_version < "4.0"
31
+ nvidia-cuda-nvrtc-cu12==12.1.105 ; platform_system == "Linux" and platform_machine == "x86_64" and python_version >= "3.11" and python_version < "4.0"
32
+ nvidia-cuda-runtime-cu12==12.1.105 ; platform_system == "Linux" and platform_machine == "x86_64" and python_version >= "3.11" and python_version < "4.0"
33
+ nvidia-cudnn-cu12==8.9.2.26 ; platform_system == "Linux" and platform_machine == "x86_64" and python_version >= "3.11" and python_version < "4.0"
34
+ nvidia-cufft-cu12==11.0.2.54 ; platform_system == "Linux" and platform_machine == "x86_64" and python_version >= "3.11" and python_version < "4.0"
35
+ nvidia-curand-cu12==10.3.2.106 ; platform_system == "Linux" and platform_machine == "x86_64" and python_version >= "3.11" and python_version < "4.0"
36
+ nvidia-cusolver-cu12==11.4.5.107 ; platform_system == "Linux" and platform_machine == "x86_64" and python_version >= "3.11" and python_version < "4.0"
37
+ nvidia-cusparse-cu12==12.1.0.106 ; platform_system == "Linux" and platform_machine == "x86_64" and python_version >= "3.11" and python_version < "4.0"
38
+ nvidia-nccl-cu12==2.19.3 ; platform_system == "Linux" and platform_machine == "x86_64" and python_version >= "3.11" and python_version < "4.0"
39
+ nvidia-nvjitlink-cu12==12.4.127 ; platform_system == "Linux" and platform_machine == "x86_64" and python_version >= "3.11" and python_version < "4.0"
40
+ nvidia-nvtx-cu12==12.1.105 ; platform_system == "Linux" and platform_machine == "x86_64" and python_version >= "3.11" and python_version < "4.0"
41
+ orjson==3.10.12 ; python_version >= "3.11" and python_version < "4.0"
42
+ packaging==24.2 ; python_version >= "3.11" and python_version < "4.0"
43
+ pandas==2.2.3 ; python_version >= "3.11" and python_version < "4.0"
44
+ pillow==11.0.0 ; python_version >= "3.11" and python_version < "4.0"
45
+ pyaudio==0.2.14 ; python_version >= "3.11" and python_version < "4.0"
46
+ pydantic-core==2.27.1 ; python_version >= "3.11" and python_version < "4.0"
47
+ pydantic==2.10.3 ; python_version >= "3.11" and python_version < "4.0"
48
+ pydub==0.25.1 ; python_version >= "3.11" and python_version < "4.0"
49
+ pygments==2.18.0 ; python_version >= "3.11" and python_version < "4.0" and sys_platform != "emscripten"
50
+ python-dateutil==2.9.0.post0 ; python_version >= "3.11" and python_version < "4.0"
51
+ python-multipart==0.0.12 ; python_version >= "3.11" and python_version < "4.0"
52
+ pytz==2024.2 ; python_version >= "3.11" and python_version < "4.0"
53
+ pyyaml==6.0.2 ; python_version >= "3.11" and python_version < "4.0"
54
+ requests==2.32.3 ; python_version >= "3.11" and python_version < "4.0"
55
+ rich==13.9.4 ; python_version >= "3.11" and python_version < "4.0" and sys_platform != "emscripten"
56
+ ruff==0.8.1 ; python_version >= "3.11" and python_version < "4.0" and sys_platform != "emscripten"
57
+ safehttpx==0.1.6 ; python_version >= "3.11" and python_version < "4.0"
58
+ scipy==1.14.1 ; python_version >= "3.11" and python_version < "4.0"
59
+ semantic-version==2.10.0 ; python_version >= "3.11" and python_version < "4.0"
60
+ shellingham==1.5.4 ; python_version >= "3.11" and python_version < "4.0" and sys_platform != "emscripten"
61
+ six==1.17.0 ; python_version >= "3.11" and python_version < "4.0"
62
+ sniffio==1.3.1 ; python_version >= "3.11" and python_version < "4.0"
63
+ starlette==0.41.3 ; python_version >= "3.11" and python_version < "4.0"
64
+ sympy==1.13.1 ; python_version >= "3.11" and python_version < "4.0"
65
+ tomlkit==0.12.0 ; python_version >= "3.11" and python_version < "4.0"
66
+ torch==2.2.2 ; python_version >= "3.11" and python_version < "4.0"
67
+ torchaudio==2.2.2 ; python_version >= "3.11" and python_version < "4.0"
68
+ torchfcpe==0.0.4 ; python_version >= "3.11" and python_version < "4.0"
69
+ tqdm==4.67.1 ; python_version >= "3.11" and python_version < "4.0"
70
+ triton==2.2.0 ; platform_system == "Linux" and platform_machine == "x86_64" and python_version < "3.12" and python_version >= "3.11"
71
+ typer==0.15.1 ; python_version >= "3.11" and python_version < "4.0" and sys_platform != "emscripten"
72
+ types-pyyaml==6.0.12.20240917 ; python_version >= "3.11" and python_version < "4.0"
73
+ typing-extensions==4.12.2 ; python_version >= "3.11" and python_version < "4.0"
74
+ tzdata==2024.2 ; python_version >= "3.11" and python_version < "4.0"
75
+ urllib3==2.2.3 ; python_version >= "3.11" and python_version < "4.0"
76
+ uvicorn==0.32.1 ; python_version >= "3.11" and python_version < "4.0" and sys_platform != "emscripten"
77
+ websockets==12.0 ; python_version >= "3.11" and python_version < "4.0"
scripts/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Scripts package for improvisation-lab."""
scripts/pitch_detection_demo.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Script for demonstrating pitch detection functionality."""
2
+
3
+ import argparse
4
+ import time
5
+
6
+ import gradio as gr
7
+
8
+ from improvisation_lab.config import Config
9
+ from improvisation_lab.domain.analysis import PitchDetector
10
+ from improvisation_lab.domain.music_theory import Notes
11
+ from improvisation_lab.infrastructure.audio import (DirectAudioProcessor,
12
+ WebAudioProcessor)
13
+
14
+
15
+ def create_process_audio(pitch_detector: PitchDetector):
16
+ """Create audio processing callback function.
17
+
18
+ Args:
19
+ pitch_detector: PitchDetector instance
20
+
21
+ Returns:
22
+ Callback function for processing audio data
23
+ """
24
+
25
+ def process_audio(audio_data):
26
+ frequency = pitch_detector.detect_pitch(audio_data)
27
+ if frequency > 0: # voice detected
28
+ note_name = Notes.convert_frequency_to_note(frequency)
29
+ print(
30
+ f"\rFrequency: {frequency:6.1f} Hz | Note: {note_name:<5}",
31
+ end="",
32
+ flush=True,
33
+ )
34
+ else: # no voice detected
35
+ print("\rNo voice detected ", end="", flush=True)
36
+
37
+ return process_audio
38
+
39
+
40
+ def run_direct_audio_demo(config: Config):
41
+ """Run pitch detection demo using microphone input.
42
+
43
+ Args:
44
+ config: Configuration object
45
+ """
46
+ pitch_detector = PitchDetector(config.audio.pitch_detector)
47
+ mic_input = DirectAudioProcessor(
48
+ sample_rate=config.audio.sample_rate,
49
+ buffer_duration=config.audio.buffer_duration,
50
+ )
51
+
52
+ print("Starting pitch detection demo (Microphone)...")
53
+ print("Sing or hum a note!")
54
+ print("-" * 50)
55
+
56
+ try:
57
+ mic_input._callback = create_process_audio(pitch_detector)
58
+ mic_input.start_recording()
59
+ while True:
60
+ time.sleep(0.1)
61
+ except KeyboardInterrupt:
62
+ print("\nStopping...")
63
+ finally:
64
+ mic_input.stop_recording()
65
+
66
+
67
+ def run_web_audio_demo(config: Config):
68
+ """Run pitch detection demo using Gradio interface.
69
+
70
+ Args:
71
+ config: Configuration object
72
+ """
73
+ pitch_detector = PitchDetector(config.audio.pitch_detector)
74
+ audio_input = WebAudioProcessor(
75
+ sample_rate=config.audio.sample_rate,
76
+ buffer_duration=config.audio.buffer_duration,
77
+ )
78
+
79
+ print("Starting pitch detection demo (Gradio)...")
80
+ result = {"text": "No voice detected"}
81
+
82
+ def process_audio(audio_data):
83
+ frequency = pitch_detector.detect_pitch(audio_data)
84
+ if frequency > 0:
85
+ note_name = Notes.convert_frequency_to_note(frequency)
86
+ result["text"] = f"Frequency: {frequency:6.1f} Hz | Note: {note_name}"
87
+ else:
88
+ result["text"] = "No voice detected"
89
+
90
+ audio_input._callback = process_audio
91
+
92
+ def handle_audio(audio):
93
+ """Handle audio input from Gradio."""
94
+ if audio is None:
95
+ return result["text"]
96
+ if not audio_input.is_recording:
97
+ audio_input.start_recording()
98
+ audio_input.process_audio(audio)
99
+ return result["text"]
100
+
101
+ interface = gr.Interface(
102
+ fn=handle_audio,
103
+ inputs=gr.Audio(
104
+ sources=["microphone"],
105
+ streaming=True,
106
+ type="numpy",
107
+ ),
108
+ outputs=gr.Text(label="Detection Result"),
109
+ live=True,
110
+ title="Pitch Detection Demo",
111
+ allow_flagging="never",
112
+ stream_every=0.05,
113
+ )
114
+ interface.queue()
115
+ interface.launch(
116
+ share=False,
117
+ debug=True,
118
+ )
119
+
120
+
121
+ def main():
122
+ """Run the pitch detection demo."""
123
+ parser = argparse.ArgumentParser(description="Run pitch detection demo")
124
+ parser.add_argument(
125
+ "--input",
126
+ choices=["direct", "web"],
127
+ default="web",
128
+ help="Input method (direct: microphone or web: browser)",
129
+ )
130
+ args = parser.parse_args()
131
+
132
+ config = Config()
133
+
134
+ if args.input == "web":
135
+ run_web_audio_demo(config)
136
+ else:
137
+ run_direct_audio_demo(config)
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()
tests/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Test package for improvisation-lab."""
tests/application/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Tests for the application layer."""
tests/application/melody_practice/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Tests for the melody practice application layer."""
tests/application/melody_practice/test_app_factory.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the MelodyPracticeAppFactory class."""
2
+
3
+ import pytest
4
+
5
+ from improvisation_lab.application.melody_practice.app_factory import \
6
+ MelodyPracticeAppFactory
7
+ from improvisation_lab.application.melody_practice.console_app import \
8
+ ConsoleMelodyPracticeApp
9
+ from improvisation_lab.application.melody_practice.web_app import \
10
+ WebMelodyPracticeApp
11
+ from improvisation_lab.config import Config
12
+ from improvisation_lab.service import MelodyPracticeService
13
+
14
+
15
+ class TestMelodyPracticeAppFactory:
16
+ @pytest.fixture
17
+ def init_module(self):
18
+ self.config = Config()
19
+ self.service = MelodyPracticeService(self.config)
20
+
21
+ @pytest.mark.usefixtures("init_module")
22
+ def test_create_web_app(self):
23
+ app = MelodyPracticeAppFactory.create_app("web", self.service, self.config)
24
+ assert isinstance(app, WebMelodyPracticeApp)
25
+
26
+ @pytest.mark.usefixtures("init_module")
27
+ def test_create_console_app(self):
28
+ app = MelodyPracticeAppFactory.create_app("console", self.service, self.config)
29
+ assert isinstance(app, ConsoleMelodyPracticeApp)
30
+
31
+ @pytest.mark.usefixtures("init_module")
32
+ def test_create_app_invalid_type(self):
33
+ with pytest.raises(ValueError):
34
+ MelodyPracticeAppFactory.create_app("invalid", self.service, self.config)
tests/application/melody_practice/test_console_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.melody_practice.console_app import \
8
+ ConsoleMelodyPracticeApp
9
+ from improvisation_lab.config import Config
10
+ from improvisation_lab.infrastructure.audio import DirectAudioProcessor
11
+ from improvisation_lab.presentation.melody_practice.console_melody_view import \
12
+ ConsoleMelodyView
13
+ from improvisation_lab.service import MelodyPracticeService
14
+
15
+
16
+ class TestConsoleMelodyPracticeApp:
17
+ @pytest.fixture
18
+ def init_module(self):
19
+ """Initialize ConsoleMelodyPracticeApp for testing."""
20
+ config = Config()
21
+ service = MelodyPracticeService(config)
22
+ self.app = ConsoleMelodyPracticeApp(service, config)
23
+ self.app.ui = Mock(spec=ConsoleMelodyView)
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(1, self.app.phrases)
tests/application/melody_practice/test_web_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.melody_practice.web_app import \
8
+ WebMelodyPracticeApp
9
+ from improvisation_lab.config import Config
10
+ from improvisation_lab.infrastructure.audio import WebAudioProcessor
11
+ from improvisation_lab.presentation.melody_practice.web_melody_view import \
12
+ WebMelodyView
13
+ from improvisation_lab.service import MelodyPracticeService
14
+
15
+
16
+ class TestWebMelodyPracticeApp:
17
+ @pytest.fixture
18
+ def init_module(self):
19
+ """Initialize WebMelodyPracticeApp for testing."""
20
+ config = Config()
21
+ service = MelodyPracticeService(config)
22
+ self.app = WebMelodyPracticeApp(service, config)
23
+ self.app.ui = Mock(spec=WebMelodyView)
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/domain/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Test package for domain layer of improvisation-lab."""
tests/domain/analysis/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Test package for music analysis module."""
tests/domain/analysis/test_pitch_detector.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from improvisation_lab.config import PitchDetectorConfig
5
+ from improvisation_lab.domain.analysis.pitch_detector import PitchDetector
6
+
7
+
8
+ class TestPitchDetector:
9
+
10
+ @pytest.fixture
11
+ def init_module(self) -> None:
12
+ """Initialization."""
13
+ config = PitchDetectorConfig()
14
+ self.pitch_detector = PitchDetector(config)
15
+
16
+ @pytest.mark.usefixtures("init_module")
17
+ def test_detect_pitch_sine_wave(self):
18
+ """Test pitch detection with a simple sine wave."""
19
+ # Create a sine wave at 440 Hz (A4 note)
20
+ duration = 0.2 # seconds
21
+ # Array of sr * duration equally spaced values dividing the range 0 to duration.
22
+ t = np.linspace(0, duration, int(self.pitch_detector.sample_rate * duration))
23
+ frequency = 440.0
24
+ # Generates sine waves for a specified time
25
+ audio_data = np.sin(2 * np.pi * frequency * t).astype(np.float32)
26
+
27
+ # Detect pitch
28
+ detected_freq = self.pitch_detector.detect_pitch(audio_data)
29
+
30
+ # Check if detected frequency is close to 440 Hz
31
+ assert abs(detected_freq - 440.0) < 1.5 # Allow 1.5 Hz tolerance
32
+
33
+ def test_custom_parameters(self):
34
+ """Test pitch detection with custom parameters."""
35
+ custom_config = PitchDetectorConfig(
36
+ sample_rate=22050,
37
+ f0_min=100,
38
+ f0_max=800,
39
+ threshold=0.01,
40
+ )
41
+ detector = PitchDetector(custom_config)
42
+
43
+ duration = 0.2
44
+ t = np.linspace(0, duration, int(detector.sample_rate * duration))
45
+ frequency = 440.0
46
+ audio_data = np.sin(2 * np.pi * frequency * t).astype(np.float32)
47
+
48
+ detected_freq = detector.detect_pitch(audio_data)
49
+ assert abs(detected_freq - 440.0) < 1.5
tests/domain/composition/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Test package for melody jam module."""