Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .github/workflows/deploy_to_hf.yml +55 -0
- .gitignore +165 -0
- Makefile +33 -0
- README.md +141 -5
- config.yml.example +20 -0
- hf_space_metadata.yml +12 -0
- improvisation_lab/__init__.py +1 -0
- improvisation_lab/application/__init__.py +1 -0
- improvisation_lab/application/melody_practice/__init__.py +6 -0
- improvisation_lab/application/melody_practice/app_factory.py +28 -0
- improvisation_lab/application/melody_practice/base_app.py +53 -0
- improvisation_lab/application/melody_practice/console_app.py +82 -0
- improvisation_lab/application/melody_practice/web_app.py +120 -0
- improvisation_lab/config.py +89 -0
- improvisation_lab/domain/__init__.py +1 -0
- improvisation_lab/domain/analysis/__init__.py +5 -0
- improvisation_lab/domain/analysis/pitch_detector.py +61 -0
- improvisation_lab/domain/composition/__init__.py +6 -0
- improvisation_lab/domain/composition/melody_composer.py +71 -0
- improvisation_lab/domain/composition/phrase_generator.py +188 -0
- improvisation_lab/domain/music_theory.py +172 -0
- improvisation_lab/infrastructure/__init__.py +1 -0
- improvisation_lab/infrastructure/audio/__init__.py +10 -0
- improvisation_lab/infrastructure/audio/audio_processor.py +53 -0
- improvisation_lab/infrastructure/audio/direct_processor.py +104 -0
- improvisation_lab/infrastructure/audio/web_processor.py +112 -0
- improvisation_lab/presentation/__init__.py +1 -0
- improvisation_lab/presentation/melody_practice/__init__.py +14 -0
- improvisation_lab/presentation/melody_practice/console_melody_view.py +56 -0
- improvisation_lab/presentation/melody_practice/view_text_manager.py +70 -0
- improvisation_lab/presentation/melody_practice/web_melody_view.py +99 -0
- improvisation_lab/service/__init__.py +6 -0
- improvisation_lab/service/melody_practice_service.py +128 -0
- main.py +33 -0
- packages.txt +1 -0
- poetry.lock +0 -0
- pyproject.toml +45 -0
- requirements.txt +77 -0
- scripts/__init__.py +1 -0
- scripts/pitch_detection_demo.py +141 -0
- tests/__init__.py +1 -0
- tests/application/__init__.py +1 -0
- tests/application/melody_practice/__init__.py +1 -0
- tests/application/melody_practice/test_app_factory.py +34 -0
- tests/application/melody_practice/test_console_app.py +71 -0
- tests/application/melody_practice/test_web_app.py +94 -0
- tests/domain/__init__.py +1 -0
- tests/domain/analysis/__init__.py +1 -0
- tests/domain/analysis/test_pitch_detector.py +49 -0
- 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 |
-
|
5 |
-
|
|
|
6 |
sdk: gradio
|
7 |
sdk_version: 5.7.1
|
8 |
-
app_file:
|
9 |
pinned: false
|
|
|
10 |
---
|
|
|
11 |
|
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 |
---
|
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."""
|