diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7f578f1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +# Check http://editorconfig.org for more information +# This is the main config file for this project: +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.{py, pyi}] +indent_style = space +indent_size = 4 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false + +[*.{diff,patch}] +trim_trailing_whitespace = false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4472907 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,33 @@ + +name: Publish + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + publish: + + runs-on: ubuntu-latest + environment: release + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..32f652f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: Test + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + test: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11.3 + uses: actions/setup-python@v3 + with: + python-version: "3.11.3" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[test] + - name: Lint + run: | + flake8 henon2midi/ + black --check henon2midi/ + black --check tests/ + mypy --ignore-missing-imports henon2midi/ + - name: Test + run: | + pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c3aab7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,614 @@ +### OSX ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### PyCharm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +.idea/**/sonarlint/ + +# SonarQube Plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator/ + +### Python ### +.venv +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version + +# poetry +.venv + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Plugins +.secrets.baseline + +### VisualStudioCode ### +.vscode/* +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### VisualStudio ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# End of https://www.gitignore.io/api/osx,python,pycharm,windows,visualstudio,visualstudiocode diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2997e0f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +default_language_version: + python: python3.9 + +default_stages: [commit, push] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.5.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + exclude: LICENSE + + - repo: local + hooks: + - id: isort + name: isort + entry: poetry run isort --settings-path pyproject.toml + types: [python] + language: system + + - repo: local + hooks: + - id: black + name: black + entry: poetry run black --config pyproject.toml + types: [python] + language: system diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..785eb9b --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2023 Josh + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3ab86d3 --- /dev/null +++ b/Makefile @@ -0,0 +1,115 @@ +.ONESHELL: +ENV_PREFIX := $(shell python3 -c "if __import__('pathlib').Path('.venv/bin/pip').exists(): print('.venv/bin/')") +EXTRA_ARGS?= +PYTHON_COMMAND?=python3 + +.PHONY: help +help: ## Show the help. + @echo "Usage: make [target] [EXTRA_ARGS=...]" + @echo "" + @echo "Targets:" + @fgrep "##" Makefile | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' + +.PHONY: show +show: ## Show the environment. + @echo "Current environment:" + @echo "Running using $(ENV_PREFIX)" + @$(ENV_PREFIX)python --version + @$(ENV_PREFIX)python -m henon2midi --version + +.PHONY: install +install: activate ## Install the in dev mode + @echo "Don't forget to run 'make virtualenv' if you get errors" + $(ENV_PREFIX)pip install -e .[test] + +.PHONY: fmt +fmt: activate ## Format code using black and isort + $(ENV_PREFIX)isort henon2midi/ + $(ENV_PREFIX)black henon2midi/ + $(ENV_PREFIX)black tests/ + +.PHONY: lint +lint: activate ## Run pep8, black, mypy linters + $(ENV_PREFIX)flake8 henon2midi/ + $(ENV_PREFIX)black --check henon2midi/ + $(ENV_PREFIX)black --check tests/ + $(ENV_PREFIX)mypy --ignore-missing-imports henon2midi/ + +.PHONY: test +test: activate ## Run tests + $(ENV_PREFIX)pytest -v -l --tb=short --maxfail=1 tests/ + $(ENV_PREFIX)coverage xml + $(ENV_PREFIX)coverage html + +.PHONY: watch +watch: ## Run tests on every change + ls **/**.py | entr $(ENV_PREFIX)pytest -s -vvv -l --tb=long --maxfail=1 tests/ + +.PHONY: clean +clean: ## Clean unused files + @find ./ -name "*.pyc" -exec rm -rf {} \; + @find ./ -name "__pycache__" -exec rm -rf {} \; + @find ./ -name "Thumbs.db" -exec rm -rf {} \; + @find ./ -name "*~" -exec rm -rf {} \; + @rm -rf .cache + @rm -rf .pytest_cache + @rm -rf .mypy_cache + @rm -rf build + @rm -rf dist + @rm -rf *.egg-info + @rm -rf htmlcov + +.PHONY: virtualenv +virtualenv: ## Create a virtual environment + @echo "Creating virtualenv..." + @rm -rf .venv + @$(PYTHON_COMMAND) -m venv .venv + @./.venv/bin/pip install -U pip + @./.venv/bin/pip install -e .[test] + @echo "" + @echo "!!! Please run 'source .venv/bin/activate' to activate the virtualenv !!!" + +.PHONY: activate +activate: ## Activate the virtual environment + @echo "Activating virtualenv..." + . ./$(ENV_PREFIX)activate + +.PHONY: run +run: ## Run the project + $(ENV_PREFIX)python -m henon2midi $(EXTRA_ARGS) + +.PHONY: build +build: ## Build the project + $(ENV_PREFIX)python -m build + +.PHONY: tag +tag: ## Create a new tag for a release + @echo "WARNING: This operation will create a new tag and push it to the remote repository." + @read -p "Version? (e.g. 0.1.0): " TAG && \ + sed -i "" "s/version = \"[0-9]\.[0-9]\.[0-9]\"/version = \"$${TAG}\"/g" pyproject.toml && \ + git add pyproject.toml && \ + git commit -m "release: version $${TAG}" && \ + echo "creating git tag : $${TAG}" && \ + git tag $${TAG} && \ + git push -u origin HEAD --tags + +.PHONY: release +release: build ## push the package to pypi + $(ENV_PREFIX)python -m twine upload dist/* + +.PHONY: pre-commit-install +pre-commit-install: activate ## Install pre-commit hooks + pre-commit install + +.PHONY: pre-commit-uninstall +pre-commit-uninstall: activate ## Uninstall pre-commit hooks + pre-commit uninstall + +.PHONY: pre-commit-run +pre-commit-run: activate ## Run pre-commit hooks + pre-commit run --all-files + +.PHONY: variables +variables: ## Show interesting variables + @echo "ENV_PREFIX: $(ENV_PREFIX)" + @echo "EXTRA_ARGS: $(EXTRA_ARGS)" diff --git a/README.md b/README.md new file mode 100644 index 0000000..0dbeb5f --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# henon2midi + +This application generates midi output and midi files from Henon map data. + +## Installation + +- Install from PyPI: +```bash +pip3 install henon2midi +``` + +## Usage + +- For help: +```bash +henon2midi --help +``` + +- Run with default settings: +```bash +henon2midi +``` +This will generate a midi file in the current working directory. + +- Sending midi output to a specific device: + +``` +henon2midi --midi-out-device device_name +``` + +Where `device_name` is the name of the midi device you want to send the output to. Use a `device name` of 'default' to use the first available device e.g. + +```bash +henon2midi --midi-out-device 'device_name' +``` + +- Enabling midi loopback driver on macOS (e.g. for use with DAWS): + 1. Open 'Audio MIDI Setup.app' + 2. Click 'Window' -> 'Show MIDI Studio' + 3. Double click 'IAC Driver' device + 4. Check 'Device is online' checkbox + 5. Click 'Apply' + 6. Make sure 'IAC Driver' enabled as a midi input device in your DAW + +## Installation from source + +- Prerequisites: + - Python 3 + - pip + +Make sure pip is up to date: +```bash +python3 -m pip install --upgrade pip +``` + +- Install package from source code: +```bash +pip install . +``` + +- Now you can run the application from the command line e.g. +```bash +henon2midi --version +``` + +## Development + +- Pre-requisites: + - Python 3 + - pip + - virtualenv + +- Use the Makefile for local development: + +1. Activate a virtual environment: +```bash +make virtualenv +``` + +2. Activate the virtual environment (not strictly necessary, if using the Makefile only): +```bash +source venv/bin/activate +``` + +3. Install dependencies: +```bash +make install +``` + +Then you can: + +- Run the application: +```bash +make run +``` +Extra arguments can be passed by providing EXTRA_ARGS variable e.g. +```bash +make run EXTRA_ARGS="--help" +``` + +- Run tests: +```bash +make test +``` + +- Run linter: +```bash +make lint +``` +`make fmt` can be used to automatically fix some linting errors. + +- Other useful make targets are provided too, see the Makefile for details. Or run `make`/`make help` diff --git a/henon2midi/__init__.py b/henon2midi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/henon2midi/__main__.py b/henon2midi/__main__.py new file mode 100644 index 0000000..f159a75 --- /dev/null +++ b/henon2midi/__main__.py @@ -0,0 +1,9 @@ +from henon2midi.cli import cli + + +def main(): + cli() + + +if __name__ == "__main__": + main() diff --git a/henon2midi/ascii_art.py b/henon2midi/ascii_art.py new file mode 100644 index 0000000..26543f1 --- /dev/null +++ b/henon2midi/ascii_art.py @@ -0,0 +1,52 @@ +import random + + +class AsciiArtCanvas: + RESET_COLOR = "\033[0m" + + COLORS = { + "black": "\033[0;30m", + "red": "\033[0;31m", + "green": "\033[0;32m", + "yellow": "\033[0;33m", + "blue": "\033[0;34m", + "magenta": "\033[0;35m", + "cyan": "\033[0;36m", + "white": "\033[0;37m", + "bright_black": "\033[1;30m", + "bright_red": "\033[1;31m", + "bright_green": "\033[1;32m", + "bright_yellow": "\033[1;33m", + "bright_blue": "\033[1;34m", + "bright_magenta": "\033[1;35m", + "bright_cyan": "\033[1;36m", + "bright_white": "\033[1;37m", + } + + def __init__(self, width: int = 120, height: int = 80): + self.width = width + self.height = height + self.current_color = "white" + self.canvas = [[" " for _ in range(width)] for _ in range(height)] + + def draw_point(self, x: int, y: int, character: str = "X"): + color_escape_code = self.COLORS[self.current_color] + self.canvas[y][x] = color_escape_code + character + + def set_color(self, color: str): + if color == "random": + color = random.choice(list(self.COLORS.keys())) + elif color not in self.COLORS: + raise Exception(f"Color {color} not supported") + + self.current_color = color + + def clear(self): + self.canvas = [[" " for _ in range(self.width)] for _ in range(self.height)] + + def generate_string(self): + return ( + self.RESET_COLOR + + "\n".join(["".join(row) for row in self.canvas]) + + self.RESET_COLOR + ) diff --git a/henon2midi/base.py b/henon2midi/base.py new file mode 100644 index 0000000..ac5fcf7 --- /dev/null +++ b/henon2midi/base.py @@ -0,0 +1,17 @@ +from mido import MetaMessage, MidiFile, MidiTrack, bpm2tempo + +from henon2midi.henon_midi_generator import HenonMidiGenerator + + +def create_midi_file_from_midi_generator( + henon_midi_generator: HenonMidiGenerator, + ticks_per_beat: int = 960, + bpm: int = 120, +) -> MidiFile: + mid = MidiFile(ticks_per_beat=ticks_per_beat) + track = MidiTrack() + mid.tracks.append(track) + tempo = bpm2tempo(bpm) + track.append(MetaMessage("set_tempo", tempo=tempo)) + track.extend(henon_midi_generator.generate_all_midi_messages()) + return mid diff --git a/henon2midi/cli.py b/henon2midi/cli.py new file mode 100644 index 0000000..75c112f --- /dev/null +++ b/henon2midi/cli.py @@ -0,0 +1,265 @@ +import click +import pkg_resources +from mido import Message + +from henon2midi.ascii_art import AsciiArtCanvas +from henon2midi.base import create_midi_file_from_midi_generator +from henon2midi.henon_midi_generator import HenonMidiGenerator +from henon2midi.math import rescale_number_to_range +from henon2midi.midi import ( + MidiMessagePlayer, + get_available_midi_output_names, + get_default_midi_output_name, +) + + +@click.version_option() +@click.command() +@click.option( + "-a", + "--a-parameter", + default=1.0, + help="The a parameter for the Henon mapping.", + show_default=True, + type=float, +) +@click.option( + "-i", + "--iterations-per-orbit", + default=100, + help="The number of iterations per orbit.", + show_default=True, + type=int, +) +@click.option( + "-m", + "--midi-output-name", + default=get_default_midi_output_name(), + help=( + "The name of the MIDI output device, " + "Available: [{}]".format(", ".join(set(get_available_midi_output_names()))) + ), + show_default=True, + type=str, +) +@click.option( + "--ticks-per-beat", + default=960, + help="The number of ticks per beat.", + show_default=True, + type=int, +) +@click.option( + "--bpm", + default=120, + help="The beats per minute.", + show_default=True, + type=int, +) +@click.option( + "--notes-per-beat", + default=4, + help="The number of notes per beat.", + show_default=True, + type=int, +) +@click.option( + "--x-midi-parameter-mappings", + default="note", + help="The MIDI parameter mappings for the x data point.", + show_default=True, + type=str, +) +@click.option( + "--y-midi-parameter-mappings", + default="velocity,pan", + help="The MIDI parameter mappings for the y data point.", + show_default=True, + type=str, +) +@click.option( + "-r", + "--starting-radius", + default=0.0, + help="The starting radius for the Henon mapping.", + show_default=True, + type=float, +) +@click.option( + "-s", + "--radial-step", + default=0.01, + help="The radial step for the Henon mapping.", + show_default=True, + type=float, +) +@click.option( + "-o", + "--out", + default="henon.mid", + help="The path to the output MIDI file.", + show_default=True, + type=str, +) +@click.option( + "--draw-ascii-art", + is_flag=True, + help="Draw the Henon mapping in ASCII art.", + type=bool, +) +@click.option("--sustain", is_flag=True, help="Turn the sustain on.", type=bool) +@click.option( + "--clip", + is_flag=True, + help="Clip the MIDI messages to the range of the MIDI parameter.", + type=bool, +) +def cli( + a_parameter: float, + iterations_per_orbit: int, + midi_output_name: str, + ticks_per_beat: int, + bpm: int, + notes_per_beat: int, + x_midi_parameter_mappings: str, + y_midi_parameter_mappings: str, + starting_radius: float, + radial_step: float, + out: str, + draw_ascii_art: bool, + sustain: bool, + clip: bool, +): + """An application that generates midi from procedurally generated Henon mappings.""" + + package = "henon2midi" + version = pkg_resources.require(package)[0].version + version_string = package + " v" + version + "\n\n" + click.echo(version_string) + + midi_output_file_name = out + if midi_output_name == "default": + midi_output_name = get_default_midi_output_name() + else: + midi_output_name = midi_output_name + ticks_per_beat = ticks_per_beat + bpm = bpm + notes_per_beat = notes_per_beat + x_midi_parameter_mappings_set = set(x_midi_parameter_mappings.split(",")) + y_midi_parameter_mappings_set = set(y_midi_parameter_mappings.split(",")) + starting_radius = starting_radius + radial_step = radial_step + draw_ascii_art = draw_ascii_art + sustain = sustain + clip = clip + + options_string = ( + "Running with the following parameters. Use --help to see all available options.\n" + f"\ta parameter: {a_parameter}\n" + f"\titerations per orbit: {iterations_per_orbit}\n" + f"\tmidi output name: {midi_output_name}\n" + f"\tticks per beat: {ticks_per_beat}\n" + f"\tbpm: {bpm}\n" + f"\tnotes per beat: {notes_per_beat}\n" + f"\tx midi parameter mappings: {x_midi_parameter_mappings_set}\n" + f"\ty midi parameter mappings: {y_midi_parameter_mappings_set}\n" + f"\tstarting radius: {starting_radius}\n" + f"\tradial step: {radial_step}\n" + f"\tout: {midi_output_file_name}\n" + f"\tdraw ascii art: {draw_ascii_art}\n" + f"\tsustain: {sustain}\n" + f"\tclip: {clip}\n" + f"\n" + ) + click.echo(options_string) + + henon_midi_generator = HenonMidiGenerator( + a_parameter=a_parameter, + iterations_per_orbit=iterations_per_orbit, + starting_radius=starting_radius, + radial_step=radial_step, + note_length_ticks=ticks_per_beat // notes_per_beat, + sustain=sustain, + clip=clip, + x_midi_parameter_mappings=x_midi_parameter_mappings_set, + y_midi_parameter_mappings=y_midi_parameter_mappings_set, + ) + + if midi_output_file_name: + mid = create_midi_file_from_midi_generator( + henon_midi_generator, ticks_per_beat=ticks_per_beat, bpm=bpm + ) + mid.save(midi_output_file_name) + + if draw_ascii_art: + ascii_art_canvas_width = 160 + ascii_art_canvas_height = 80 + ascii_art_canvas = AsciiArtCanvas( + ascii_art_canvas_width, ascii_art_canvas_height + ) + art_string = "" + + if midi_output_name: + midi_message_player = MidiMessagePlayer( + midi_output_name=midi_output_name, ticks_per_beat=ticks_per_beat, bpm=bpm + ) + while True: + messages = henon_midi_generator.next_midi_messages() + current_iteration = henon_midi_generator.current_iteration + current_orbit = ( + current_iteration // henon_midi_generator.iterations_per_orbit + ) + try: + midi_message_player.send(messages) + except KeyboardInterrupt: + midi_message_player.midi_output.reset() + for note in range(128): + midi_message_player.midi_output.send( + Message("note_off", note=note, velocity=0) + ) + sustain_off_msg = Message("control_change", control=64, value=0) + midi_message_player.midi_output.send(sustain_off_msg) + midi_message_player.midi_output.close() + exit() + current_state_string = ( + f"Current iteration: {current_iteration}\n" + f"Current orbit: {current_orbit + 1}\n" + "\n" + ) + + if draw_ascii_art: + art_string = get_ascii_art( + henon_midi_generator.current_data_point, + current_iteration, + henon_midi_generator.iterations_per_orbit, + ascii_art_canvas, + ) + + click.clear() + screen_render = ( + version_string + options_string + current_state_string + art_string + ) + click.echo(screen_render) + + +def get_ascii_art( + data_point: tuple[float, float], + current_iteration, + iterations_per_orbit, + ascii_art_canvas: AsciiArtCanvas, +) -> str: + if current_iteration == 0: + ascii_art_canvas.clear() + x = data_point[0] + y = data_point[1] + draw_point_coord = ( + round(rescale_number_to_range(x, (-1.0, 1.0), (0, ascii_art_canvas.width - 1))), + round( + rescale_number_to_range(y, (-1.0, 1.0), (0, ascii_art_canvas.height - 1)) + ), + ) + ascii_art_canvas.draw_point(draw_point_coord[0], draw_point_coord[1], ".") + + if current_iteration % iterations_per_orbit == 0: + ascii_art_canvas.set_color("random") + return ascii_art_canvas.generate_string() diff --git a/henon2midi/henon_equations.py b/henon2midi/henon_equations.py new file mode 100644 index 0000000..51741c8 --- /dev/null +++ b/henon2midi/henon_equations.py @@ -0,0 +1,52 @@ +from math import cos, sin +from typing import Callable, Generator + + +def equation_a(x: float, y: float, a: float) -> float: + return (x * cos(a)) - ((y - x**2) * sin(a)) + + +def equation_b(x: float, y: float, a: float) -> float: + return (x * sin(a)) + ((y - x**2) * cos(a)) + + +# TODO: try these equations +# def four_parameter_equation_a(x: float, y: float, a: float, b: float, c: float, d: float) -> float: +# return (sin(a*y)) - (cos(b*x)) + +# def four_parameter_equation_b(x: float, y: float, a: float, b: float, c: float, d: float) -> float: +# return (sin(c*x)) - (cos(d*y)) + + +def henon_mapping_generator( + a_parameter: float, + initial_x: float, + initial_y: float, + equation_a: Callable = equation_a, + equation_b: Callable = equation_b, +) -> Generator[tuple[float, float], None, None]: + x = initial_x + y = initial_y + while True: + try: + x_next, y_next = equation_a(x, y, a_parameter), equation_b( + x, y, a_parameter + ) + x, y = x_next, y_next + except OverflowError: + break + else: + yield x, y + + +def radially_expanding_henon_mappings_generator( + a_parameter: float, + iterations_per_orbit: int = 32, + starting_radius: float = 0.0, + radial_step: float = 0.1, +) -> Generator[tuple[float, float], None, None]: + radius = starting_radius + while radius <= 1: + radius += radial_step + for _ in range(iterations_per_orbit): + yield from henon_mapping_generator(a_parameter, radius, radius) diff --git a/henon2midi/henon_midi_generator.py b/henon2midi/henon_midi_generator.py new file mode 100644 index 0000000..7aa729c --- /dev/null +++ b/henon2midi/henon_midi_generator.py @@ -0,0 +1,198 @@ +from mido import Message + +from henon2midi.henon_equations import radially_expanding_henon_mappings_generator +from henon2midi.math import rescale_number_to_range + + +class HenonMidiGenerator: + def __init__( + self, + a_parameter: float, + iterations_per_orbit: int = 50, + starting_radius: float = 0.0, + radial_step: float = 0.05, + note_length_ticks: int = 960, + sustain: bool = False, + clip: bool = False, + x_midi_parameter_mappings: set[str] = {"note"}, + y_midi_parameter_mappings: set[str] = {"velocity", "pan"}, + ): + self.a_parameter = a_parameter + self.iterations_per_orbit = iterations_per_orbit + self.starting_radius = starting_radius + self.radial_step = radial_step + self.note_length_ticks = note_length_ticks + self.sustain = sustain + self.clip = clip + self.x_midi_parameter_mappings = x_midi_parameter_mappings + self.y_midi_parameter_mappings = y_midi_parameter_mappings + self.current_iteration = 0 + self.current_radius = self.starting_radius + self.current_data_point = (self.starting_radius, self.starting_radius) + self.current_iteration_midi_messages: list[Message] = [] + self.datapoint_generator = radially_expanding_henon_mappings_generator( + a_parameter=self.a_parameter, + iterations_per_orbit=self.iterations_per_orbit, + starting_radius=self.starting_radius, + radial_step=self.radial_step, + ) + self.reset() + + def next_midi_messages(self) -> list[Message]: + try: + datapoint = next(self.datapoint_generator) + except StopIteration: + self.reset() + midi_messages = self.current_iteration_midi_messages + else: + self.current_data_point = datapoint + self.current_iteration += 1 + if self.current_iteration % self.iterations_per_orbit == 0: + self.current_radius += self.radial_step + self.datapoint_generator = radially_expanding_henon_mappings_generator( + a_parameter=self.a_parameter, + iterations_per_orbit=self.iterations_per_orbit, + starting_radius=self.current_radius, + radial_step=self.radial_step, + ) + midi_messages = create_midi_messages_from_data_point( + datapoint, + duration_ticks=self.note_length_ticks, + sustain=self.sustain, + clip=self.clip, + x_midi_parameter_mappings=self.x_midi_parameter_mappings, + y_midi_parameter_mappings=self.y_midi_parameter_mappings, + ) + self.current_iteration_midi_messages = midi_messages + return midi_messages + + def reset(self): + self.current_iteration = 0 + self.current_radius = self.starting_radius + self.current_data_point = (self.starting_radius, self.starting_radius) + self.current_iteration_midi_messages = create_midi_messages_from_data_point( + self.current_data_point, + duration_ticks=self.note_length_ticks, + sustain=self.sustain, + clip=self.clip, + x_midi_parameter_mappings=self.x_midi_parameter_mappings, + y_midi_parameter_mappings=self.y_midi_parameter_mappings, + ) + self.datapoint_generator = radially_expanding_henon_mappings_generator( + a_parameter=self.a_parameter, + iterations_per_orbit=self.iterations_per_orbit, + starting_radius=self.starting_radius, + radial_step=self.radial_step, + ) + + def generate_all_midi_messages(self) -> list[Message]: + self.reset() + complete = False + midi_messages = [] + midi_messages.extend(self.current_iteration_midi_messages) + while not complete: + midi_messages.extend(self.next_midi_messages()) + if self.current_iteration == 0: + complete = True + return midi_messages + + +def create_midi_messages_from_data_point( + datapoint: tuple[float, float], + duration_ticks: float = 960, + sustain: bool = False, + clip: bool = False, + x_midi_parameter_mappings: set[str] = {"note"}, + y_midi_parameter_mappings: set[str] = {"velocity", "pan"}, +) -> list[Message]: + x = datapoint[0] + y = datapoint[1] + + midi_values = { + "note": 64, + "velocity": 64, + "pan": 64, + } + + for x_midi_parameter_mapping in x_midi_parameter_mappings: + if (x > 1.0 or x < -1.0) and not clip: + midi_values[x_midi_parameter_mapping] = 0 + else: + midi_values[x_midi_parameter_mapping] = midi_value_from_data_point_value(x) + + for y_midi_parameter_mapping in y_midi_parameter_mappings: + if (y > 1.0 or y < -1.0) and not clip: + midi_values[y_midi_parameter_mapping] = 0 + else: + midi_values[y_midi_parameter_mapping] = midi_value_from_data_point_value(y) + + note_on = Message( + "note_on", + note=midi_values["note"], + velocity=midi_values["velocity"], + ) + note_off = Message( + "note_off", + note=midi_values["note"], + velocity=midi_values["velocity"], + time=duration_ticks, + ) + + note_messages = [note_on, note_off] + pre_note_messages = [] + post_note_messages = [] + + if "pan" in x_midi_parameter_mappings or "pan" in y_midi_parameter_mappings: + pan = Message( + "control_change", + control=10, + value=midi_values["pan"], + ) + pre_note_messages.append(pan) + + reset_pan = Message( + "control_change", + control=10, + value=64, + ) + post_note_messages.append(reset_pan) + + if sustain: + sustain_on_msg = Message( + "control_change", + control=64, + value=127, + ) + pre_note_messages.append(sustain_on_msg) + else: + sustain_off_msg = Message( + "control_change", + control=64, + value=0, + ) + post_note_messages.append(sustain_off_msg) + + messages = pre_note_messages + note_messages + post_note_messages + + return messages + + +def midi_value_from_data_point_value( + x: float, + data_point_range: tuple[float, float] = (-1.0, 1.0), + midi_range: tuple[int, int] = (0, 127), +) -> int: + min_midi_value = midi_range[0] + max_midi_value = midi_range[1] + + min_data_point_value = data_point_range[0] + max_data_point_value = data_point_range[1] + + return round( + rescale_number_to_range( + x, + (min_data_point_value, max_data_point_value), + (min_midi_value, max_midi_value), + clip_value=True, + ) + ) diff --git a/henon2midi/math.py b/henon2midi/math.py new file mode 100644 index 0000000..269eed6 --- /dev/null +++ b/henon2midi/math.py @@ -0,0 +1,27 @@ +def rescale_number_to_range( + x: float, + initial_range: tuple[float, float], + new_range: tuple[float, float], + clip_value: bool = True, +) -> float: + if clip_value: + if x < initial_range[0]: + x = initial_range[0] + elif x > initial_range[1]: + x = initial_range[1] + else: + if x < initial_range[0] or x > initial_range[1]: + raise ValueError(f"x ({x}) is not within initial_range ({initial_range})") + + initial_range_min = initial_range[0] + initial_range_max = initial_range[1] + initial_range_size = initial_range_max - initial_range_min + + new_range_min = new_range[0] + new_range_max = new_range[1] + new_range_size = new_range_max - new_range_min + + scale_factor = new_range_size / initial_range_size + x_rescaled = ((x - initial_range_min) * scale_factor) + new_range_min + + return x_rescaled diff --git a/henon2midi/midi.py b/henon2midi/midi.py new file mode 100644 index 0000000..73ee552 --- /dev/null +++ b/henon2midi/midi.py @@ -0,0 +1,51 @@ +from time import sleep, time + +from mido import ( + Message, + MidiFile, + bpm2tempo, + get_output_names, + open_output, + tick2second, +) +from mido.backends.rtmidi import Output + + +class MidiMessagePlayer: + def __init__( + self, midi_output_name: str, ticks_per_beat: int = 960, bpm: int = 120 + ): + self.midi_output: Output = open_output(midi_output_name) + self.ticks_per_beat = ticks_per_beat + self.tempo = bpm2tempo(bpm) + self.playback_start_time = time() + self.input_time = 0.0 + + def send(self, messages: list[Message]): + for msg in messages: + time_s = tick2second( + msg.time, ticks_per_beat=self.ticks_per_beat, tempo=self.tempo + ) + self.input_time += time_s + current_playback_time = time() - self.playback_start_time + duration_to_next_event_s = self.input_time - current_playback_time + + if duration_to_next_event_s > 0: + sleep(duration_to_next_event_s) + + self.midi_output.send(msg) + + +def get_available_midi_output_names(): + return get_output_names() + + +def get_default_midi_output_name() -> str: + output_names = get_available_midi_output_names() + if len(output_names) == 0: + raise Exception("No MIDI output devices found") + return output_names[0] + + +def save_midi_file(midi: MidiFile, filename: str): + midi.save(filename) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a1590f5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[project] +name = "henon2midi" +version = "0.0.1" +description = "This application generates midi bifurcation diagrams generated from generated logistic map data." +readme = "README.md" +authors = [ + {name = "Josh Symes"} +] +dependencies = [ + "mido", + "click", + "python-rtmidi", + "colorama" +] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] + +[project.optional-dependencies] +test = [ + "pytest", + "pytest-cov", + "pytest-mock", + "coverage", + "flake8", + "black", + "isort", + "mypy", + "types-setuptools", + "pre-commit" +] + +[project.scripts] +henon2midi = "henon2midi.__main__:main" + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +exclude = ["docs*", "tests*"] + +[tool.coverage.run] +source = ["henon2midi"] + +[tool.pytest.ini_options] +addopts = "--cov henon2midi --cov-report term-missing --cov-report xml --cov-report html --cov-branch" + +[tool.pylint] +max-line-length = 120 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..6deafc2 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 120 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_math.py b/tests/test_math.py new file mode 100644 index 0000000..eed9c71 --- /dev/null +++ b/tests/test_math.py @@ -0,0 +1,53 @@ +"""Tests for hello function.""" +import pytest + +from henon2midi.math import rescale_number_to_range + + +@pytest.mark.parametrize( + ("value", "initial_range", "new_range", "expected"), + [ + (-1.0, (-1.0, 1.0), (0, 127), 0.0), + (0.0, (-1.0, 1.0), (0, 127), 63.5), + (1.0, (-1.0, 1.0), (0, 127), 127.0), + (-1.0, (-1.0, 1.0), (10, 20), 10.0), + (0.0, (-1.0, 1.0), (10, 20), 15.0), + (1.0, (-1.0, 1.0), (10, 20), 20.0), + ], +) +def test_rescale_number_to_range(value, initial_range, new_range, expected): + assert ( + rescale_number_to_range(value, initial_range, new_range, clip_value=False) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "initial_range", "new_range", "expected"), + [ + (-1000.0, (-1.0, 1.0), (0, 127), 0), + (0.0, (-1.0, 1.0), (0, 127), 63.5), + (100.0, (-1.0, 1.0), (0, 127), 127), + ], +) +def test_rescale_number_to_range_with_clipping( + value, initial_range, new_range, expected +): + assert ( + rescale_number_to_range(value, initial_range, new_range, clip_value=True) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "initial_range", "new_range"), + [ + (-1000.0, (-1.0, 1.0), (0, 127)), + (100.0, (-1.0, 1.0), (0, 127)), + ], +) +def test_rescale_number_to_range_value_out_of_initial_range_raises_error( + value, initial_range, new_range +): + with pytest.raises(ValueError): + rescale_number_to_range(value, initial_range, new_range, clip_value=False) diff --git a/tests/test_midi.py b/tests/test_midi.py new file mode 100644 index 0000000..4aeb23b --- /dev/null +++ b/tests/test_midi.py @@ -0,0 +1,23 @@ +"""Tests for hello function.""" +import pytest + +from henon2midi.midi import get_default_midi_output_name + + +@pytest.fixture +def mock_get_output_names(mocker): + mocker.patch("henon2midi.midi.get_output_names", return_value=["Bus 1", "Bus 2"]) + + +@pytest.fixture +def mock_get_output_names_empty(mocker): + mocker.patch("henon2midi.midi.get_output_names", return_value=[]) + + +def test_get_default_midi_output_name(mock_get_output_names): + assert get_default_midi_output_name() == "Bus 1" + + +def test_get_default_midi_output_name_empty(mock_get_output_names_empty): + with pytest.raises(Exception): + get_default_midi_output_name()