packaging

This commit is contained in:
Dominic DiTaranto 2026-08-09 21:08:41 -04:00
parent b985581b74
commit d2b54ae907
47 changed files with 1124 additions and 0 deletions

9
LICENSE.md Normal file
View file

@ -0,0 +1,9 @@
The MIT License (MIT)
Copyright (c) 2017 Dylan Araps
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.

2
MANIFEST.in Normal file
View file

@ -0,0 +1,2 @@
graft src/atonatui/assets
global-exclude *.py[cod]

25
PKGBUILD Normal file
View file

@ -0,0 +1,25 @@
# Maintainer: Dominic DiTaranto <me@domdit.com>
pkgname=atonatui
_name=atonatui
pkgver=0.1.1
pkgrel=1
pkgdesc="12 Tone Matrix Generator, Looper, Sequencer."
arch=('any')
url="https://git.domdit.com/dominic/atonatui"
license=('MIT')
depends=('python')
makedepends=('python-build' 'python-installer' 'python-wheel' 'python-setuptools')
source=("https://pythonhosted.org{_name::1}/$_name/$_name-$pkgver.tar.gz")
sha256sums=('SKIP')
build() {
cd "$_name-$pkgver"
python -m build --wheel --no-isolation
}
package() {
cd "$_name-$pkgver"
python -m installer --destdir="$pkgdir" dist/*.whl
install -Dm644 LICENSE.md "$pkgdir/usr/share/licenses/$pkgname/LICENSE.md"
}

BIN
dist/atonatui-0.1.1-py3-none-any.whl vendored Normal file

Binary file not shown.

BIN
dist/atonatui-0.1.1.tar.gz vendored Normal file

Binary file not shown.

41
pyproject.toml Normal file
View file

@ -0,0 +1,41 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "atonatui"
version = "0.1.1"
description = "12 Tone Matrix Generator, Looper, Sequencer."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [
{ name = "Dominic DiTaranto", email = "me@domdit.com" }
]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Environment :: Console",
"Topic :: Multimedia :: Sound/Audio :: Analysis"
]
dependencies = [
"numpy==2.5.1",
"prompt_toolkit==3.0.53",
"pyfluidsynth==1.4.0",
"wcwidth==0.8.2"
]
[project.urls]
Homepage = "https://git.domdit.com/dominic/atonatui"
[project.scripts]
atonatui = "atonatui.main:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
# Tells setuptools to track assets inside the python environment
atonatui = ["assets/**/*", "assets/*"]

View file

@ -0,0 +1,113 @@
Metadata-Version: 2.4
Name: atonatui
Version: 0.1.1
Summary: 12 Tone Matrix Generator, Looper, Sequencer.
Author-email: Dominic DiTaranto <me@domdit.com>
License: MIT
Project-URL: Homepage, https://git.domdit.com/dominic/atonatui
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Environment :: Console
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE.md
Requires-Dist: numpy==2.5.1
Requires-Dist: prompt_toolkit==3.0.53
Requires-Dist: pyfluidsynth==1.4.0
Requires-Dist: wcwidth==0.8.2
Dynamic: license-file
# Twelve Tone Generator
TUI Application for composing serialism music.
Features:
- Generate 12 Tone Matrix
- Play 12 Tone rows with over 15 instruments
- Loop and/or Sequence 12 tone rows
- Generate Serialized Octave Sequences based on tone matrix
- Generate Serialized Rhythm Sequences based on tone matrix
- Save Matrices and Other Sequences for later use
## Installation Guide
PIP: `pipx install atonatui`
ARCH: `yay -S python-atonatui`
## Main Page
The main page consists of a 12 Tone Matrix.
The only part of the main page that really needs explaining is the tri-colored table below the matrix.
- The TONE row contains the tones of P0 row.
- RTHM is the rhythmic division assigned to a given note.
- OCTV is the octave assigned to a given note.
The rhythmic division and octave will always be the same for their associated note, the order just changes when you play a row other than P0.
When you do begin playback, this tri-colored table will be replaced with the row that is currently being played.
## Commands
| Command | Action | Page |
|---|---|---|
| `P0-P11` | Play Prime Row | Main Page |
| `I0-I11` | Play Inversion Row | Main Page |
| `R0-R11` | Play Retrograde Row | Main Page |
| `i0-i11` | Play Retrograde Inversion Row | Main Page |
| `loop <count> <P0-i11>` | Play row X times in loop | Main Page |
| `seq <P0-i11>,<P0-i11>... ` | Play sequence of rows, comma separated | Main Page |
| `r` OR `regen` | Regenerate Matrix | Main Page |
| `s` OR `settings` | Go To Settings Page | Main Page |
| `h` OR `help` | Open This Document | Main Page |
| `q` OR `quit` | Quit Application | Main Page, Settings Page |
| Setting ID | Edit Setting | Settings Page |
__Advanced Commands:__
- Loop a sequence:
- `loop <x> seq <P0-i11>,<P0-i11>`
- Example: `loop 5 seq P0,R3,i11,I0`
- Debug
- `debug`: Open up python console via breakpoint()
## Settings:
- BPM
- Set the BPM of the overall project, between 30 - 300 BPM
- INSTRUMENT
- Select an instrument for playback
- OCTAVE
- Select base octave for playback
- OCTAVE_RANDOMIZATION
- `0` Only play base octave
- `1` Randomize octaves, increasing octave range by +1 and -1 (example: base octave = 3, octave range = 2-4)
- `2` Randomize octaves, increasing octave range by +2 and -2 (example: base octave = 3, octave range = 1-5)
- `3` Randomize octaves, increasing octave range by +3 and -3 (example: base octave = 1, octave range = 1-4)
- When the range would result in an octave higher than 5 or lower than 0, it clamps range to fit 0-5
- RHYTHM_COMPLEXITY
- `0` No Rhythmic Complexity, Will play default division
- `1` Will play a rhythm based on sequence generated by tone matrix
- `2` Will play a rhythm based on sequence generated by tone matrix, Including rests (if rest probability is greater than 0)
- `3` Will play a random rhythm not based on a sequence
- DEFAULT_DIVISION
- `0` Whole Note
- `1` Half Note
- `2` Quarter Note
- `3` Eight Note
- REST_PROBABILITY
- The probability in which a rest will occur during playback when RHYTHM_COMPLEXITY = 2
- SAVE_SETTINGS
- Save current settings to a file, will automatically load when you restart the program
- SAVE_MATRIX
- Save the current tone matrix to a file, you can manually load this later
- LOAD_MATRIX
- Load a previously saved tone matrix
# Upcoming Features
- Support independent generation, saving, and loading for rhythm and octave rows
- Support loading of custom soundfonts
- Support custom input of Tone Row + Rhythm and Octave rows
- Output rows to midi
- Up arrow functionality so users can rerun commands easily

View file

@ -0,0 +1,38 @@
LICENSE.md
MANIFEST.in
README.md
pyproject.toml
src/atonatui/__init__.py
src/atonatui/audio_mixin.py
src/atonatui/constants.py
src/atonatui/display_mixin.py
src/atonatui/input_mixin.py
src/atonatui/main.py
src/atonatui/settings_mixin.py
src/atonatui.egg-info/PKG-INFO
src/atonatui.egg-info/SOURCES.txt
src/atonatui.egg-info/dependency_links.txt
src/atonatui.egg-info/entry_points.txt
src/atonatui.egg-info/requires.txt
src/atonatui.egg-info/top_level.txt
src/atonatui/assets/settings.json
src/atonatui/assets/audio/SJO - Bassoon.sf2
src/atonatui/assets/audio/SJO - Cello.sf2
src/atonatui/assets/audio/SJO - Choir Aahs.sf2
src/atonatui/assets/audio/SJO - Clarinet.sf2
src/atonatui/assets/audio/SJO - Double Bass.sf2
src/atonatui/assets/audio/SJO - English Horn.sf2
src/atonatui/assets/audio/SJO - Flute.sf2
src/atonatui/assets/audio/SJO - French Horn.sf2
src/atonatui/assets/audio/SJO - Glockenspiel.sf2
src/atonatui/assets/audio/SJO - Harp.sf2
src/atonatui/assets/audio/SJO - Marimbas.sf2
src/atonatui/assets/audio/SJO - Strings slow.sf2
src/atonatui/assets/audio/SJO - Strings.sf2
src/atonatui/assets/audio/SJO - Timpani.sf2
src/atonatui/assets/audio/SJO - Trombone.sf2
src/atonatui/assets/audio/SJO - Trumpet - Velocity fix.sf2
src/atonatui/assets/audio/SJO - Tuba.sf2
src/atonatui/assets/audio/SJO - Vibes.sf2
src/atonatui/assets/audio/SJO - Viola.sf2
src/atonatui/assets/audio/SJO - Xylophone.sf2

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,2 @@
[console_scripts]
atonatui = atonatui.main:main

View file

@ -0,0 +1,4 @@
numpy==2.5.1
prompt_toolkit==3.0.53
pyfluidsynth==1.4.0
wcwidth==0.8.2

View file

@ -0,0 +1 @@
atonatui

0
src/atonatui/__init__.py Normal file
View file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
{"BPM": 150, "INSTRUMENT": "Xylophone", "OCTAVE": 4, "OCTAVE_RANDOMIZATION": 0, "RHYTHM_COMPLEXITY": 0, "DEFAULT_DIVISION": 3, "REST_PROBABILITY": 0, "SAVE_SETTINGS": null, "SAVE_MATRIX": "oops", "LOAD_MATRIX": null}

View file

@ -0,0 +1,50 @@
import time
import os
from importlib import resources
import fluidsynth
from atonatui.constants import NOTE_TO_MIDI, GENERAL_MIDI_PROGRAMS, SOUNDFONTS
from atonatui.settings_mixin import Settings
class AudioMixin:
def __init__(self):
self.fs = fluidsynth.Synth()
self.fs.start(driver="alsa" if os.name != "nt" else "dsound")
self.sf_id = None
self.update_sf(SOUNDFONTS[Settings.INSTRUMENT])
self.clear_screen()
def update_sf(self, path):
if self.sf_id is not None and self.sf_id != -1:
self.fs.sfunload(self.sf_id)
try:
audio_dir = resources.files("atonatui").joinpath("assets/audio")
sf2_path = str(audio_dir.joinpath(path))
except Exception:
sf2_path = os.path.join(os.path.dirname(__file__), "assets/audio", path)
self.sf_id = self.fs.sfload(sf2_path)
self.fs.program_select(0, self.sf_id, 0, GENERAL_MIDI_PROGRAMS[Settings.INSTRUMENT])
def play(self, note_name, duration, rest=False):
if note_name not in NOTE_TO_MIDI:
return
midi_num = NOTE_TO_MIDI[note_name]
channel = 0
velocity = 100
if not rest:
self.fs.noteon(channel, midi_num, velocity)
time.sleep(duration)
if not rest:
self.fs.noteoff(channel, midi_num)
def close(self):
self.fs.delete()

172
src/atonatui/constants.py Normal file
View file

@ -0,0 +1,172 @@
BPM = 180
QUARTER = 60.0 / BPM
HALF = QUARTER * 2
NOTE_TO_FREQ = {
"C1": 32.70, "C#1": 34.65, "D1": 36.71, "D#1": 38.89, "E1": 41.20, "F1": 43.65,
"F#1": 46.25, "G1": 49.00, "G#1": 51.91, "A1": 55.00, "A#1": 58.27, "B1": 61.74,
"C2": 65.41, "C#2": 69.30, "D2": 73.42, "D#2": 77.78, "E2": 82.41, "F2": 87.31,
"F#2": 92.50, "G2": 98.00, "G#2": 103.83, "A2": 110.00, "A#2": 116.54, "B2": 123.47,
"C3": 130.81, "C#3": 138.59, "D3": 146.83, "D#3": 155.56, "E3": 164.81, "F3": 174.61,
"F#3": 185.00, "G3": 196.00, "G#3": 207.65, "A3": 220.00, "A#3": 233.08, "B3": 246.94,
"C4": 261.63, "C#4": 277.18, "D4": 293.66, "D#4": 311.13, "E4": 329.63, "F4": 349.23,
"F#4": 369.99, "G4": 392.00, "G#4": 415.30, "A4": 440.00, "A#4": 466.16, "B4": 493.88,
"C5": 523.25, "C#5": 554.37, "D5": 587.33, "D#5": 622.25, "E5": 659.25, "F5": 698.46,
"F#5": 739.99, "G5": 783.99, "G#5": 830.61, "A5": 880.00, "A#5": 932.33, "B5": 987.77
}
NOTE_TO_MIDI = {
"C1": 24, "C#1": 25, "D1": 26, "D#1": 27, "E1": 28, "F1": 29, "F#1": 30, "G1": 31, "G#1": 32, "A1": 33, "A#1": 34, "B1": 35,
"C2": 36, "C#2": 37, "D2": 38, "D#2": 39, "E2": 40, "F2": 41, "F#2": 42, "G2": 43, "G#2": 44, "A2": 45, "A#2": 46, "B2": 47,
"C3": 48, "C#3": 49, "D3": 50, "D#3": 51, "E3": 52, "F3": 53, "F#3": 54, "G3": 55, "G#3": 56, "A3": 57, "A#3": 58, "B3": 59,
"C4": 60, "C#4": 61, "D4": 62, "D#4": 63, "E4": 64, "F4": 65, "F#4": 66, "G4": 67, "G#4": 68, "A4": 69, "A#4": 70, "B4": 71,
"C5": 72, "C#5": 73, "D5": 74, "D#5": 75, "E5": 76, "F5": 77, "F#5": 78, "G5": 79, "G#5": 80, "A5": 81, "A#5": 82, "B5": 83
}
NOTES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
SOUNDFONTS = {
'Bassoon': 'SJO - Bassoon.sf2',
'Cello': 'SJO - Cello.sf2',
'Choir Aahs': 'SJO - Choir Aahs.sf2',
'Clarinet': 'SJO - Clarinet.sf2',
'Double Bass': 'SJO - Double Bass.sf2',
'English Horn': 'SJO - English Horn.sf2',
'Flute': 'SJO - Flute.sf2',
'French Horn': 'SJO - French Horn.sf2',
'Glockenspiel': 'SJO - Glockenspiel.sf2',
'Harp': 'SJO - Harp.sf2',
'Marimbas': 'SJO - Marimbas.sf2',
'Strings slow': 'SJO - Strings slow.sf2',
'Strings': 'SJO - Strings.sf2',
'Timpani': 'SJO - Timpani.sf2',
'Trombone': 'SJO - Trombone.sf2',
'Trumpet - Velocity fix': 'SJO - Trumpet - Velocity fix.sf2',
'Tuba': 'SJO - Tuba.sf2',
'Vibes': 'SJO - Vibes.sf2',
'Viola': 'SJO - Viola.sf2',
'Xylophone': 'SJO - Xylophone.sf2'
}
GENERAL_MIDI_PROGRAMS = {
'Glockenspiel': 9,
'Vibes': 11,
'Marimbas': 12,
'Xylophone': 13,
'Harp': 46,
'Timpani': 47,
'Strings': 48,
'Strings slow': 49,
'Viola': 41,
'Cello': 42,
'Double Bass': 43,
'Choir Aahs': 52,
'Trumpet - Velocity fix': 56,
'Trombone': 57,
'Tuba': 58,
'French Horn': 60,
'Flute': 73,
'English Horn': 69,
'Clarinet': 71,
'Bassoon': 70
}
RESET = "\033[0m"
BOLD = "\033[1m"
REVERSE = "\033[7m"
BLACK = "\033[30m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
WHITE = "\033[37m"
README1 = f'''
{REVERSE}ATONATUI README{RESET}
The main page consists of a 12 Tone Matrix.
The only part of the main page that really needs explaining is the tri-colored table below the matrix.
- The TONE row contains the tones of P0 row.
- RTHM is the rhythmic division assigned to a given note.
- OCTV is the octave assigned to a given note.
The rhythmic division and octave will always be the same for their associated note, the order just changes when you play a row other than P0.
When you do begin playback, this tri-colored table will be replaced with the row that is currently being played.
'''
README2 = f'''
{REVERSE}{GREEN}| Command | Action | Page |{RESET}
| P0-P11 | Play Prime Row | Main Page |
| I0-I11 | Play Inversion Row | Main Page |
| R0-R11 | Play Retrograde Row | Main Page |
| i0-i11 | Play Retrograde Inversion Row | Main Page |
| loop <count> <P0-i11> | Play row X times in loop | Main Page |
| seq <P0-i11>,<P0-i11>... | Play sequence, comma separated | Main Page |
| r OR regen | Regenerate Matrix | Main Page |
| s OR settings | Go To Settings Page | Main Page |
| h OR help | Open This Document | Main Page |
| q OR quit | Quit Application | Main, Settings Page |
| Setting ID | Edit Setting | Settings Page |
Advanced Commands:
- Loop a sequence:
- loop <x> seq <P0-i11>,<P0-i11>
- Example: loop 5 seq P0,R3,i11,I0
- Debug
- debug: Open up python console via breakpoint()
'''
README3 = f'''
{REVERSE}{GREEN}Settings:{RESET}
- BPM
- Set the BPM of the overall project, between 30 - 300 BPM
- INSTRUMENT
- Select an instrument for playback
- OCTAVE
- Select base octave for playback
- OCTAVE_RANDOMIZATION
- [0] Only play base octave
- [1] Randomize octaves, increasing octave range by +1 and -1 (example: base octave = 3, octave range = 2-4)
- [2] Randomize octaves, increasing octave range by +2 and -2 (example: base octave = 3, octave range = 1-5)
- [3] Randomize octaves, increasing octave range by +3 and -3 (example: base octave = 1, octave range = 1-4)
- When the range would result in an octave higher than 5 or lower than 0, it clamps range to fit 0-5
- RHYTHM_COMPLEXITY
- [0] No Rhythmic Complexity, Will play default division
- [1] Will play a rhythm based on sequence generated by tone matrix
- [2] Will play a rhythm based on sequence generated by tone matrix, Including rests (if rest probability is greater than 0)
- [3] Will play a random rhythm not based on a sequence
'''
README4 = f'''
{REVERSE}{GREEN}Settings Continued:{RESET}
- DEFAULT_DIVISION
- 0 Whole Note
- 1 Half Note
- 2 Quarter Note
- 3 Eight Note
- REST_PROBABILITY
- The probability in which a rest will occur during playback when RHYTHM_COMPLEXITY = 2
- SAVE_SETTINGS
- Save current settings to a file, will automatically load when you restart the program
- SAVE_MATRIX
- Save the current tone matrix to a file, you can manually load this later
- LOAD_MATRIX
- Load a previously saved tone matrix
'''
README5 = f'''
{REVERSE}{GREEN}Thank you for using atonatui{RESET}
Creator: Dominic DiTaranto
Website: https://www.domdit.com/
Email: me@domdit.com
v.0.1.2
'''

View file

@ -0,0 +1,62 @@
import subprocess
from time import sleep
from atonatui.constants import RESET, REVERSE, BOLD, GREEN, RED, BLUE, YELLOW, MAGENTA
from atonatui.settings_mixin import Settings
class DisplayMixin:
def message(self, msg, callback, sleep_time=1.5, type="ERROR"):
self.clear_screen()
color = RED if type == "ERROR" else GREEN
print(f"{REVERSE}{color}{msg}{RESET}")
sleep(sleep_time)
self.clear_screen()
callback()
def clear_screen(self):
subprocess.run(['clear'])
def display(self, play_mode=False):
print(f"{GREEN}=================================== ATONATUI ===================================={RESET}\n")
print(f"{YELLOW}{Settings.BPM} BPM - OCTAVE {Settings.OCTAVE} - {Settings.INSTRUMENT} {RESET}")
print(f'{GREEN}{REVERSE} {BOLD}{" | ".join(f"I{i}".ljust(3) for i in self.inversion)} {RESET}')
for row, prime in zip(self.string_matrix, self.prime):
padded_row = [note.ljust(3) for note in row]
pr_label = self.reordered_notes.index(row[0])
if not self.pr_map_populated:
self.pr_map[f"P{pr_label}"] = row
print(f'{BOLD}{GREEN}{REVERSE}{f"P{pr_label}".ljust(3)}{RESET} | {" | ".join(padded_row)}| {BOLD}{GREEN}{REVERSE}{f"R{pr_label}".ljust(3)}{RESET}')
self.pr_map_populated = True
if not self.inv_map_populated:
for idx, i in enumerate(self.inversion):
new_row = []
for idx2, row in enumerate(self.matrix):
new_row.append(self.reordered_notes[row[idx]])
self.inv_map[f"I{i}"] = new_row
self.inv_map_populated = True
print(f'{GREEN}{REVERSE} {BOLD}{" | ".join(f"i{i}".ljust(3) for i in self.inversion)} {RESET}\n')
if not play_mode:
print(f"{MAGENTA}{BOLD}{REVERSE}TONE | {' | '.join([i.ljust(3) for i in self.tone_row])} {RESET}")
print(f"{YELLOW}{BOLD}{REVERSE}RTHM | {' | '.join([str(x).ljust(3) for x in self.rhythm_row])} {RESET}")
print(f"{GREEN}{BOLD}{REVERSE}OCTV | {' | '.join([str(x).ljust(3) for x in self.octave_row])} {RESET}")
def loop(self):
self.clear_screen()
self.display()
self.get_user_input()
self.loop()

102
src/atonatui/input_mixin.py Normal file
View file

@ -0,0 +1,102 @@
import sys
from prompt_toolkit import prompt
from prompt_toolkit.formatted_text import HTML
from atonatui.constants import README1, README2, README3, README4, README5
class InputMixin:
def get_user_input(self):
loop_count = 1
request = self.request(help_text='Type "help" for more options, "q" to quit')
self.check_valid_request(request)
request = request.split(' ')
if "loop" in request:
loop_count = self.parse_loop_request(request)
if "seq" in request:
self.parse_seq_request(request, loop_count)
if "debug" in request:
breakpoint()
if request[0] == "x" or request[0] == "regen":
self.regenerate()
self.loop()
elif request[0] == "q" or request[0] == "quit":
self.close()
sys.exit()
elif request[0] == "s" or request[0] == "settings":
self.display_settings()
elif request[0] == "h" or request[0] == "help":
self.display_help()
elif request[0][0] in ["P", "R", "I", "i"]:
self.play_tone_row(request[0][0], request[0][1], loop=loop_count)
def display_help(self):
for readme in [README1, README2, README3, README4, README5]:
self.clear_screen()
print(readme)
self.request("Press Enter to Continue")
self.loop()
def request(self, help_text):
return prompt(
"\n>>> ", placeholder=HTML(f'<style fg="#888888">{help_text}</style>')
)
def check_valid_request(self, request):
if not request or request == "":
self.loop()
def parse_loop_request(self, request):
loop_index = request.index("loop")
loop_count = request[loop_index + 1]
if not loop_count.isdigit():
self.message("Not a valid loop count", self.loop)
else:
loop_count = int(loop_count)
request.pop(loop_index + 1)
request.pop(loop_index)
return loop_count
def parse_seq_request(self, request, loop_count):
seq_index = request.index("seq")
sequence = request[seq_index + 1].split(',')
for seq in sequence:
if seq[0] not in ["P", "R", "I", "i"] or int(seq[1:]) > 11:
self.message(f"Invalid sequence at {seq}", self.loop)
for loop in range(loop_count):
for seq in sequence:
self.play_tone_row(seq[0], seq[1:], loop=1)
def play_tone_row(self, row_type, row_id, loop=1):
reverse = True if row_type in ["R", "i"] else False
if row_id in [str(x) for x in range(0, 12)]:
if not reverse:
if row_type == "I":
target_row = self.inv_map[row_type + row_id]
else:
target_row = self.pr_map[row_type + row_id]
else:
row_type = "P" if row_type == "R" else "I"
if row_type == "I":
target_row = self.inv_map[row_type + row_id][::-1]
else:
target_row = self.pr_map[row_type + row_id][::-1]
for i in range(loop):
self.play_row(target_row)

273
src/atonatui/main.py Normal file
View file

@ -0,0 +1,273 @@
import random
from collections import defaultdict
from functools import cached_property
from atonatui.constants import NOTES, RESET, REVERSE, MAGENTA, GREEN, YELLOW, BOLD, WHITE
from atonatui.audio_mixin import AudioMixin
from atonatui.display_mixin import DisplayMixin
from atonatui.settings_mixin import Settings, SettingsMixin
from atonatui.input_mixin import InputMixin
class ToneRow(AudioMixin, DisplayMixin, SettingsMixin, InputMixin):
def __init__(self):
AudioMixin.__init__(self)
self.bpm = Settings.BPM
self.pr_map = {}
self.pr_map_populated = False
self.inv_map = {}
self.inv_map_populated = False
self.current_seed = None
self.use_current_seed = False
self.current_rhythm_seed = None
self.use_current_rhythm_seed = False
self.current_octave_seed = None
self.use_current_octave_seed = False
self.rhythm_to_divison_map = {
0: self.whole,
1: self.half,
2: self.quarter,
3: self.eigth
}
self.generate()
self.load_settings()
@cached_property
def octaves(self):
return [str(Settings.OCTAVE)] * 12
@property
def eigth(self):
return self.quarter / 2
@property
def quarter(self):
return 60.0 / self.bpm
@property
def half(self):
return self.quarter * 2
@property
def whole(self):
return self.half * 2
@cached_property
def tone_row(self):
if not self.use_current_seed:
self.current_seed = random.randint(100000, 999999)
else:
self.use_current_seed = False
random.seed(self.current_seed)
notes = NOTES.copy()
random.shuffle(notes)
random.seed(None)
return notes
@cached_property
def reordered_notes(self):
first_note_idx = NOTES.index(self.tone_row[0])
notes = NOTES.copy()
return notes[first_note_idx:] + notes[0:first_note_idx]
@cached_property
def prime(self):
prime = []
for tone in self.tone_row:
prime.append(self.reordered_notes.index(tone))
return prime
@cached_property
def inversion(self):
inversion = []
for note in self.prime:
inversion.append((2 * self.prime[0] - note) % 12)
return inversion
@cached_property
def matrix(self):
matrix = []
for shift in self.inversion:
row = []
for item in self.prime:
x = (item + shift) % 12
row.append(x)
matrix.append(row)
return matrix
@cached_property
def string_matrix(self):
smatrix = []
for row in self.matrix:
new_row = []
for tone in row:
new_row.append(self.reordered_notes[tone])
smatrix.append(new_row)
return smatrix
@cached_property
def rhythm_row(self):
if not self.use_current_rhythm_seed:
self.current_rhythm_seed = random.randint(100000, 999999)
else:
self.use_current_rhythm_seed = False
random.seed(self.current_rhythm_seed)
roptions = [0, 1, 2, 3]
random.shuffle(roptions)
random.seed(None)
return roptions * 3
@cached_property
def rhythm_map(self):
prime = self.string_matrix[0]
rmap = defaultdict(int)
for r, t in zip(self.rhythm_row, prime):
rmap[t] = r
return rmap
@cached_property
def octave_row(self):
if not self.use_current_octave_seed:
self.current_octave_seed = random.randint(100000, 999999)
else:
self.use_current_octave_seed = False
random.seed(self.current_octave_seed)
octave_options = [Settings.OCTAVE]
for i in range(Settings.OCTAVE_RANDOMIZATION):
positive = Settings.OCTAVE + (i + 1)
if positive > 5:
positive = 5
if positive not in octave_options:
octave_options.append(positive)
negative = Settings.OCTAVE - (i + 1)
if negative < 1:
negative = 1
if negative not in octave_options:
octave_options.append(negative)
random.shuffle(octave_options)
octave_row = []
left = 0
while len(octave_row) < 12:
octave_row.append(octave_options[left])
left += 1
if left > len(octave_options) - 1:
left = 0
random.seed(None)
return octave_row
@cached_property
def octave_map(self):
prime = self.string_matrix[0]
omap = defaultdict(int)
for o, t in zip(self.octave_row, prime):
omap[t] = o
return omap
def regenerate(self):
del self.tone_row
del self.reordered_notes
del self.prime
del self.inversion
del self.matrix
del self.string_matrix
del self.octaves
del self.rhythm_row
del self.rhythm_map
del self.octave_row
del self.octave_map
self.pr_map_populated = False
self.inv_map_populated = False
self.generate()
def generate(self):
_ = self.tone_row
_ = self.reordered_notes
_ = self.prime
_ = self.inversion
_ = self.matrix
_ = self.string_matrix
_ = self.octaves
_ = self.rhythm_row
_ = self.rhythm_map
_ = self.octave_row
_ = self.octave_map
def play_row(self, row):
division_map = {
3: self.eigth,
2: self.quarter,
1: self.half,
0: self.whole
}
division = division_map[Settings.DEFAULT_DIVISION]
for idx, note in enumerate(row):
padded_tone_row = [f'{BOLD}{REVERSE}{MAGENTA}TONE']
padded_rhythm_row = [f'{BOLD}{REVERSE}{YELLOW}RTHM']
padded_octave_row = [f'{BOLD}{REVERSE}{GREEN}OCTV']
for idx2, x in enumerate(row):
if idx == idx2:
padded_tone_row.append(f"{REVERSE}{WHITE}{x.ljust(3)}{RESET}{REVERSE}{MAGENTA}{BOLD}")
padded_rhythm_row.append(f"{REVERSE}{WHITE}{str(self.rhythm_map[x]).ljust(3)}{RESET}{REVERSE}{YELLOW}{BOLD}")
padded_octave_row.append(f"{REVERSE}{WHITE}{str(self.octave_map[x]).ljust(3)}{RESET}{REVERSE}{GREEN}{BOLD}")
else:
padded_tone_row.append(x.ljust(3))
padded_rhythm_row.append(str(self.rhythm_map[x]).ljust(3))
padded_octave_row.append(str(self.octave_map[x]).ljust(3))
self.clear_screen()
self.display(play_mode=True)
print(" | ".join(padded_tone_row) + f" {RESET}")
print(" | ".join(padded_rhythm_row) + f" {RESET}")
print(" | ".join(padded_octave_row) + f" {RESET}")
if Settings.RHYTHM_COMPLEXITY == 0:
self.play(note + str(self.octave_map[note]), division)
if Settings.RHYTHM_COMPLEXITY in [1, 2]:
rest = False
if Settings.RHYTHM_COMPLEXITY == 2:
dice_roll = random.randint(1, 100)
if dice_roll <= Settings.REST_PROBABILITY:
rest = True
division = self.rhythm_to_divison_map[self.rhythm_map[note]]
self.play(note + str(self.octave_map[note]), division, rest=rest)
if Settings.RHYTHM_COMPLEXITY == 3:
division = random.uniform(0.05, 1)
self.play(note + str(self.octave_map[note]), division)
def main():
t = ToneRow()
t.loop()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,228 @@
import os
import json
from pathlib import Path
from importlib import resources
from functools import cached_property
from dataclasses import dataclass, fields
from atonatui.constants import RESET, REVERSE, SOUNDFONTS
_INSTRUMENT_LIST = [f"[{str(idx).rjust(2)}] {name}" for idx, name in enumerate(SOUNDFONTS.keys())]
_USER_SAVE_DIR = Path.home() / ".local" / "share" / "atonatui" / "saves"
try:
_DEFAULT_SETTINGS_PATH = resources.files("atonatui").joinpath("assets/settings.json")
except Exception:
_DEFAULT_SETTINGS_PATH = os.path.join(os.path.dirname(__file__), "assets", "settings.json")
_USER_CONFIG_DIR = Path.home() / ".config" / "atonatui"
_SETTINGS_SAVE_FILE = _USER_CONFIG_DIR / "settings.json"
_MATRIX_SAVE_DIR = Path.home() / ".local" / "share" / "atonatui" / "saves"
def get_saved_matrix_list():
if not _MATRIX_SAVE_DIR.exists():
return [" [No saved matrices found]"]
save_dir_str = str(_MATRIX_SAVE_DIR)
save_files = [
f for f in os.listdir(save_dir_str)
if os.path.isfile(os.path.join(save_dir_str, f)) and f.endswith(".sav")
]
save_files.sort()
if not save_files:
return [" [No saved matrices found]"]
return [f"[{str(idx).rjust(2)}] {name}" for idx, name in enumerate(save_files)]
_SAVED_MATRIX_LIST = get_saved_matrix_list()
@dataclass
class Settings:
BPM: int = 140
INSTRUMENT: str = "Xylophone"
OCTAVE: int = 3
OCTAVE_RANDOMIZATION: int = 0
RHYTHM_COMPLEXITY: int = 0
DEFAULT_DIVISION: int = 2
REST_PROBABILITY: int = 0
SAVE_SETTINGS: None = None
SAVE_MATRIX: None = None
LOAD_MATRIX: None = None
@classmethod
def max_len(cls):
return max(len(f.name) for f in fields(cls)) + 2
@dataclass
class SettingsHelp:
BPM: str = "Enter a BPM between 30 - 300"
INSTRUMENT: str = f"Select Soundfont:\n\n{'\n'.join(_INSTRUMENT_LIST)}"
OCTAVE: str = "Change the octave. Select an octave between 1 and 5"
OCTAVE_RANDOMIZATION: str = "\n[0] Only play base octave\n[1] Randomize octaves, increasing octave range by +1 and -1 (example: base octave = 3, octave range = 2-4)\n[2] Randomize octaves, increasing octave range by +2 and -2 (example: base octave = 3, octave range = 1-5)\n[3] Randomize octaves, increasing octave range by +3 and -3 (example: base octave = 1, octave range = 1-4)\n\nWhen the range would result in an octave higher than 5 or lower than 0, it clamps range to fit 0-5"
RHYTHM_COMPLEXITY: str = "\n[0] Off, Use DEFAULT_DIVISION\n[1] On, Engage Rhythm Matrix\n[2] On, Rhythm Matrix and Rest Randomization\n[3] On, Chaos Mode, A random duration is chosen between 0.05 seconds and 1 second"
DEFAULT_DIVISION: str = "\n[0] Whole Note\n[1] Half Note\n[2] Quarter Note\n[3] Eight Note"
REST_PROBABILITY: str = "Probability in which a note in row will be replaced by a rest.\nEnter a value between 0 and 100, where 0 is absolutely no chance of a rest and 100 is all rests.\nThis only works in RHYTHM_COMPLEXITY MODE 2."
SAVE_SETTINGS: str = "Save current settings to file"
SAVE_MATRIX: str = "Save current matrix to file, Enter name of matrix to save"
LOAD_MATRIX: str = f"Select Saved Matrix to Load:\n\n{'\n'.join(_SAVED_MATRIX_LIST)}"
EXIT: None = None
SETTINGS_RULES = {
"BPM": {"type": int, "min": 30, "max": 300},
"OCTAVE": {"type": int, "min": 1, "max": 5},
"OCTAVE_RANDOMIZATION": {"type": int, "min": 0, "max": 4},
"RHYTHM_COMPLEXITY": {"type": int, "min": 0, "max": 3},
"DEFAULT_DIVISION": {"type": int, "min": 0, "max": 3},
"REST_PROBABILITY": {"type": int, "min": 0, "max": 100},
"INSTRUMENT": {"type": int, "min": 0, "max": len(SOUNDFONTS)},
"SAVE_MATRIX": {"type": str, "min": 0, "max": 10},
"LOAD_MATRIX": {"type": str, "min": 0, "max": 100},
}
class SettingsMixin:
@cached_property
def settings(self):
return [f.name for f in fields(Settings)]
def save_settings(self):
_USER_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
setting_obj = {}
for field in fields(Settings):
setting_obj[field.name] = getattr(Settings, field.name)
with open(_SETTINGS_SAVE_FILE, "w+") as f:
json.dump(setting_obj, f)
def load_settings(self):
target_file = _SETTINGS_SAVE_FILE if _SETTINGS_SAVE_FILE.exists() else _DEFAULT_SETTINGS_PATH
with open(target_file, "r") as f:
data = json.load(f)
for k, v in data.items():
setattr(Settings, k, int(v) if str(v).isdigit() else v)
self.update_sf(SOUNDFONTS[Settings.INSTRUMENT])
self.bpm = int(Settings.BPM)
def display_settings(self):
self.clear_screen()
print(f"{REVERSE} SETTINGS: {RESET}\n")
for idx, setting in enumerate(self.settings):
value = getattr(Settings, setting)
print(f"[{idx}] {setting.ljust(Settings.max_len())}{": " + str(value) if value != None else ''}")
self.user_input()
self.display_settings()
def user_input(self, setting_name=None):
request = self.request('Enter ID of setting you want to change, "q" to exit')
if request[0] == "q":
self.loop()
request = self.validate_request(request)
setting_name = self.settings[request]
if setting_name == "SAVE_SETTINGS":
self.save_settings()
self.message("Settings Saved..", self.display_settings, type="SUCCESS")
self.display_setting(setting_name)
value = self.request("Update setting by entering value, 'q' to exit")
value = self.validate_new_setting(setting_name, value)
setattr(Settings, setting_name, value)
if setting_name == "LOAD_MATRIX":
self.handle_load_matrix()
if setting_name == "SAVE_MATRIX":
self.handle_save_matrix()
if setting_name == "BPM":
self.bpm = int(Settings.BPM)
if setting_name == "INSTRUMENT":
instrument = list(SOUNDFONTS.keys())[value]
setattr(Settings, setting_name, instrument)
self.update_sf(SOUNDFONTS[instrument])
if setting_name == "OCTAVE_RANDOMIZATION":
del self.octave_row
del self.octave_map
_ = self.octave_row
_ = self.octave_map
if setting_name == "OCTAVE":
del self.octave_row
del self.octave_map
_ = self.octave_row
_ = self.octave_map
def validate_request(self, request):
try:
request = int(request)
if request >= len(self.settings):
raise ValueError
return request
except ValueError:
self.message(f"'{request}' is not a valid input, try again!", self.display_settings)
def display_setting(self, setting_name):
self.clear_screen()
print(f"{REVERSE}{setting_name.ljust(Settings.max_len())} CURRENT VALUE: {getattr(Settings, setting_name)} {RESET}\n")
print(f"INFO: {getattr(SettingsHelp, setting_name)}\n")
def validate_new_setting(self, setting_name, value):
if value == "q":
self.display_settings()
try:
if SETTINGS_RULES[setting_name]['type'] == int:
value = int(value)
if value >= SETTINGS_RULES[setting_name]['min'] and value <= SETTINGS_RULES[setting_name]['max']:
pass
else:
raise ValueError
return value
else:
return value
except ValueError:
self.message(f"'{value}' is not a valid input, try again!", self.display_settings)
def handle_load_matrix(self):
file_name = " ".join(_SAVED_MATRIX_LIST[int(Settings.LOAD_MATRIX)].split("] ")[1:])
target_file = _USER_SAVE_DIR / file_name
with open(target_file, 'r') as f:
seed = f.readline()
self.current_seed = int(seed)
self.regenerate()
self.use_current_seed = True
def handle_save_matrix(self):
_USER_SAVE_DIR.mkdir(parents=True, exist_ok=True)
cleaned_name = Settings.SAVE_MATRIX.strip().replace(".", " ").replace(" ", "_")
Settings.SAVE_MATRIX = cleaned_name
target_file = _USER_SAVE_DIR / f"{cleaned_name}.sav"
with open(target_file, 'w+') as f:
f.write(str(self.current_seed))