CreativePython 0.3.6__tar.gz → 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. {creativepython-0.3.6/src/CreativePython.egg-info → creativepython-1.0.0}/PKG-INFO +2 -4
  2. {creativepython-0.3.6 → creativepython-1.0.0}/pyproject.toml +4 -6
  3. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/notationRenderer.py +70 -20
  4. {creativepython-0.3.6 → creativepython-1.0.0/src/CreativePython.egg-info}/PKG-INFO +2 -4
  5. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython.egg-info/requires.txt +1 -4
  6. {creativepython-0.3.6 → creativepython-1.0.0}/src/gui.py +82 -53
  7. {creativepython-0.3.6 → creativepython-1.0.0}/src/image.py +0 -1
  8. {creativepython-0.3.6 → creativepython-1.0.0}/src/midi.py +6 -1
  9. {creativepython-0.3.6 → creativepython-1.0.0}/src/music.py +47 -24
  10. {creativepython-0.3.6 → creativepython-1.0.0}/src/timer.py +10 -12
  11. {creativepython-0.3.6 → creativepython-1.0.0}/src/zipf.py +176 -22
  12. {creativepython-0.3.6 → creativepython-1.0.0}/LICENSE +0 -0
  13. {creativepython-0.3.6 → creativepython-1.0.0}/LICENSE-PSF +0 -0
  14. {creativepython-0.3.6 → creativepython-1.0.0}/MANIFEST.in +0 -0
  15. {creativepython-0.3.6 → creativepython-1.0.0}/README.md +0 -0
  16. {creativepython-0.3.6 → creativepython-1.0.0}/setup.cfg +0 -0
  17. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/RealtimeAudioPlayer.py +0 -0
  18. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/__init__.py +0 -0
  19. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/Java-Comparison-Tests/advMetricRunner.pythonSurvey.py +0 -0
  20. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/Java-Comparison-Tests/compareMetrics_Java-Vs-Python.py +0 -0
  21. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/RunMetrics.py +0 -0
  22. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/Surveyor.py +0 -0
  23. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/__init__.py +0 -0
  24. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/data/Confidence.py +0 -0
  25. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/data/Contig.py +0 -0
  26. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/data/ExtendedNote.py +0 -0
  27. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/data/Histogram.py +0 -0
  28. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/data/Judgement.py +0 -0
  29. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/data/Measurement.py +0 -0
  30. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/data/PianoRoll.py +0 -0
  31. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/data/PianoRollOld.py +0 -0
  32. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/data/__init__.py +0 -0
  33. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/data/test_ExtendedNote.py +0 -0
  34. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/data/test_Histogram.py +0 -0
  35. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/data/test_Measurement.py +0 -0
  36. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/data/test_PianoRoll_assertions.py +0 -0
  37. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/data/test_PianoRoll_integration.py +0 -0
  38. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/data/test_PianoRoll_quantization.py +0 -0
  39. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/data/test_PianoRoll_unit.py +0 -0
  40. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/Metric.py +0 -0
  41. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/ZipfMetrics.py +0 -0
  42. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/__init__.py +0 -0
  43. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/ChordDensityMetric.py +0 -0
  44. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/ChordDistanceMetric.py +0 -0
  45. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/ChordMetric.py +0 -0
  46. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/ChordNormalizedMetric.py +0 -0
  47. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/ChromaticToneMetric.py +0 -0
  48. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/ContourBasslineDurationMetric.py +0 -0
  49. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/ContourBasslineDurationQuantizedMetric.py +0 -0
  50. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/ContourBasslinePitchMetric.py +0 -0
  51. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyDurationMetric.py +0 -0
  52. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyDurationQuantizedMetric.py +0 -0
  53. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyPitchMetric.py +0 -0
  54. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/DurationBigramMetric.py +0 -0
  55. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/DurationDistanceMetric.py +0 -0
  56. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/DurationMetric.py +0 -0
  57. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedBigramMetric.py +0 -0
  58. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedDistanceMetric.py +0 -0
  59. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedMetric.py +0 -0
  60. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/HarmonicBigramMetric.py +0 -0
  61. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/HarmonicConsonanceMetric.py +0 -0
  62. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/HarmonicIntervalMetric.py +0 -0
  63. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/MelodicBigramMetric.py +0 -0
  64. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/MelodicConsonanceMetric.py +0 -0
  65. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/MelodicIntervalMetric.py +0 -0
  66. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/PitchDistanceMetric.py +0 -0
  67. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/PitchDurationMetric.py +0 -0
  68. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/PitchDurationQuantizedMetric.py +0 -0
  69. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/PitchMetric.py +0 -0
  70. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/RestMetric.py +0 -0
  71. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/__init__.py +0 -0
  72. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/test_DurationMetric.py +0 -0
  73. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_BasicIntervalsAndBigrams.py +0 -0
  74. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_ChordsAndConsonance.py +0 -0
  75. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_ContoursAndChromatic.py +0 -0
  76. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_QuantizedDurationsAndDistances.py +0 -0
  77. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/test_PitchMetric.py +0 -0
  78. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/simple/test_RestMetric.py +0 -0
  79. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/metrics/test_Metric.py +0 -0
  80. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/utilities/CSVWriter.py +0 -0
  81. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/utilities/PowerLawRandom.py +0 -0
  82. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython/nevmuse/utilities/__init__.py +0 -0
  83. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython.egg-info/SOURCES.txt +0 -0
  84. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython.egg-info/dependency_links.txt +0 -0
  85. {creativepython-0.3.6 → creativepython-1.0.0}/src/CreativePython.egg-info/top_level.txt +0 -0
  86. {creativepython-0.3.6 → creativepython-1.0.0}/src/bin/libportaudio.2.dylib +0 -0
  87. {creativepython-0.3.6 → creativepython-1.0.0}/src/iannix.py +0 -0
  88. {creativepython-0.3.6 → creativepython-1.0.0}/src/markov.py +0 -0
  89. {creativepython-0.3.6 → creativepython-1.0.0}/src/osc.py +0 -0
  90. {creativepython-0.3.6 → creativepython-1.0.0}/tests/testAnimate.py +0 -0
  91. {creativepython-0.3.6 → creativepython-1.0.0}/tests/testPeer.py +0 -0
  92. {creativepython-0.3.6 → creativepython-1.0.0}/tests/test_keyEvent.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: CreativePython
3
- Version: 0.3.6
3
+ Version: 1.0.0
4
4
  Summary: A Python-based software environment for developing algorithmic art projects.
5
5
  Author-email: "Dr. Bill Manaris" <manaris@cofc.edu>, Taj Ballinger <ballingertj@g.cofc.edu>, Trevor Ritchie <ritchiets@g.cofc.edu>
6
6
  License: MIT License
@@ -65,9 +65,7 @@ Requires-Dist: pooch>=1.8
65
65
  Requires-Dist: pypianoroll>=1.0
66
66
  Requires-Dist: verovio>=5.6.0
67
67
  Requires-Dist: pymusicxml>=0.5.6
68
- Provides-Extra: dev
69
- Requires-Dist: build; extra == "dev"
70
- Requires-Dist: twine; extra == "dev"
68
+ Requires-Dist: pyinstaller>=6.16.0
71
69
  Dynamic: license-file
72
70
 
73
71
  # CreativePython
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "CreativePython"
7
- version = "0.3.6"
7
+ version = "1.0.0"
8
8
  description = "A Python-based software environment for developing algorithmic art projects."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -33,7 +33,8 @@ dependencies = [
33
33
  "pooch>=1.8",
34
34
  "pypianoroll>=1.0",
35
35
  "verovio>=5.6.0",
36
- "pymusicxml>=0.5.6"
36
+ "pymusicxml>=0.5.6",
37
+ "pyinstaller>=6.16.0"
37
38
  ]
38
39
 
39
40
  [tool.setuptools]
@@ -47,7 +48,4 @@ where = ["src"]
47
48
  "CreativePython" = ["bin/libportaudio.2.dylib"]
48
49
 
49
50
  [project.urls]
50
- Homepage = "https://jythonmusic.me"
51
-
52
- [project.optional-dependencies]
53
- dev = ["build", "twine"]
51
+ Homepage = "https://jythonmusic.me"
@@ -19,7 +19,6 @@
19
19
  import xml.etree.ElementTree as ET
20
20
  from xml.dom import minidom
21
21
  import os
22
- import subprocess
23
22
  import sys
24
23
 
25
24
  # NOTE: Local, function-level imports are used in this module. This is a
@@ -73,7 +72,7 @@ _DRUM_MAP = {
73
72
 
74
73
  ### API (called from music.py) ###############################################
75
74
 
76
- def _showNotation(score, title="Sheet Music"):
75
+ def _showNotation(score, title="Sheet Music", writeToFile=False):
77
76
  """
78
77
  Renders a Score object into visual sheet music and displays it.
79
78
 
@@ -103,10 +102,10 @@ def _showNotation(score, title="Sheet Music"):
103
102
  # fallback for interactive sessions
104
103
  script_dir = os.getcwd()
105
104
 
106
- output_dir = os.path.join(script_dir, "notation")
105
+ # output_dir = os.path.join(script_dir, "notation")
107
106
 
108
- # create the notation directory if it doesn't exist
109
- os.makedirs(output_dir, exist_ok=True)
107
+ # # create the notation directory if it doesn't exist
108
+ # os.makedirs(output_dir, exist_ok=True)
110
109
 
111
110
  # Generate filename from title
112
111
  if title and title.strip() and title.strip() != "Untitled Score":
@@ -115,7 +114,7 @@ def _showNotation(score, title="Sheet Music"):
115
114
  else:
116
115
  svg_filename = "sheet_music.svg"
117
116
 
118
- output_path = os.path.join(output_dir, svg_filename)
117
+ output_path = os.path.join(script_dir, svg_filename)
119
118
 
120
119
  # generate MusicXML
121
120
  musicxml = _scoreToMusicXML(score)
@@ -125,7 +124,7 @@ def _showNotation(score, title="Sheet Music"):
125
124
 
126
125
  if svg is not None:
127
126
  # success - save file and open in viewer
128
- _openInViewer(svg, output_path)
127
+ _openInViewer(svg, output_path, writeToFile)
129
128
  print(f"Sheet music saved to: {output_path}")
130
129
  return True
131
130
  else:
@@ -768,7 +767,10 @@ def _renderToSVG(musicxml):
768
767
  return None # verovio not available
769
768
 
770
769
  # create toolkit
771
- tk = verovio.toolkit()
770
+ tk = verovio.toolkit(False)
771
+
772
+ # ensure verovio fonts are available
773
+ tk.setResourcePath(os.path.join(os.path.dirname(verovio.__file__), "data"))
772
774
 
773
775
  # configure options for the svg output, controlling layout and appearance
774
776
  tk.setOptions({
@@ -787,7 +789,7 @@ def _renderToSVG(musicxml):
787
789
  return svg
788
790
 
789
791
 
790
- def _openInViewer(svg, path):
792
+ def _openInViewer(svg, path, writeToFile):
791
793
  """
792
794
  Saves an SVG string to a file and opens it with the default system viewer.
793
795
 
@@ -799,17 +801,65 @@ def _openInViewer(svg, path):
799
801
  svg (str): The SVG data to save and display.
800
802
  path (str): The absolute file path to save the SVG to.
801
803
  """
802
- # save to specified path
803
- with open(path, 'w', encoding='utf-8') as f:
804
- f.write(svg)
805
-
806
- # use the appropriate system command to open the file in the default viewer
807
- if sys.platform == 'win32':
808
- os.startfile(path)
809
- elif sys.platform == 'darwin': # macOS
810
- subprocess.run(['open', path])
811
- else: # linux
812
- subprocess.run(['xdg-open', path])
804
+ import pathlib, webbrowser, tempfile, urllib.parse
805
+
806
+ if writeToFile:
807
+ # save to specified path
808
+ with open(path, 'w', encoding='utf-8') as f:
809
+ f.write(svg)
810
+ svgPath = pathlib.Path(path).expanduser().resolve() # absolute path to written SVG
811
+
812
+ else:
813
+ # save to temporary location
814
+ with tempfile.NamedTemporaryFile("w", suffix=".svg", delete=False) as f:
815
+ f.write(svg)
816
+ svgPath = pathlib.Path(f.name).resolve() # absolute path to temporary SVG
817
+
818
+ if svgPath.is_file():
819
+ svgURL = svgPath.as_uri() # convert filepath to URL (with correct space handling)
820
+
821
+ # generate minimal HTML wrapper
822
+ # SVG is a generic document type, so often get opened in text editors
823
+ # (TextMate, VSCode, etc.). We want to open the SVG in the user's
824
+ # default web browser, so we wrap it in HTML before opening.
825
+ html = f"""<!doctype html>
826
+ <html>
827
+ <head>
828
+ <meta charset="utf-8">
829
+ <meta name="viewport" content="width=device-width, initial-scale=1">
830
+ <title>{svgPath.name}</title>
831
+ <style>
832
+ html, body {{ height: 100%; margin: 0; }}
833
+ body {{ display: grid; place-items: center; background: #fff; }}
834
+ img {{ max-width: 100vw; max-height: 100vh; }}
835
+ </style>
836
+ </head>
837
+ <body>
838
+ <img src="{svgURL}" alt="{svgPath.name}">
839
+ </body>
840
+ </html>
841
+ """
842
+
843
+ # write wrapper to a temporary file
844
+ with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
845
+ f.write(html)
846
+ htmlPath = pathlib.Path(f.name).resolve() # absolute path to HTML
847
+ htmlURL = htmlPath.as_uri() # convert to URL
848
+
849
+ ok = webbrowser.open(htmlURL, new=2) # open in new tab, if possible
850
+ if not ok:
851
+ webbrowser.open(htmlURL) # fallback to new browser window
852
+
853
+ else:
854
+ print(f"View.notate(): Couldn't open generated SVG '{path}' (You can open this notation in most web browsers).")
855
+
856
+ # # use the appropriate system command to open the file in the default viewer
857
+ # if sys.platform == 'win32':
858
+ # os.startfile(path)
859
+ # elif sys.platform == 'darwin': # macOS
860
+ # subprocess.run(['open', path])
861
+ # else: # linux
862
+ # subprocess.run(['xdg-open', path])
813
863
 
814
864
 
815
865
  def _saveMusicXML(musicxml, filename):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: CreativePython
3
- Version: 0.3.6
3
+ Version: 1.0.0
4
4
  Summary: A Python-based software environment for developing algorithmic art projects.
5
5
  Author-email: "Dr. Bill Manaris" <manaris@cofc.edu>, Taj Ballinger <ballingertj@g.cofc.edu>, Trevor Ritchie <ritchiets@g.cofc.edu>
6
6
  License: MIT License
@@ -65,9 +65,7 @@ Requires-Dist: pooch>=1.8
65
65
  Requires-Dist: pypianoroll>=1.0
66
66
  Requires-Dist: verovio>=5.6.0
67
67
  Requires-Dist: pymusicxml>=0.5.6
68
- Provides-Extra: dev
69
- Requires-Dist: build; extra == "dev"
70
- Requires-Dist: twine; extra == "dev"
68
+ Requires-Dist: pyinstaller>=6.16.0
71
69
  Dynamic: license-file
72
70
 
73
71
  # CreativePython
@@ -11,7 +11,4 @@ pooch>=1.8
11
11
  pypianoroll>=1.0
12
12
  verovio>=5.6.0
13
13
  pymusicxml>=0.5.6
14
-
15
- [dev]
16
- build
17
- twine
14
+ pyinstaller>=6.16.0
@@ -5,11 +5,7 @@
5
5
  import PySide6.QtWidgets as _QtWidgets
6
6
  import PySide6.QtGui as _QtGui
7
7
  import PySide6.QtCore as _QtCore
8
- import PySide6.QtSvg as _QtSvg
9
8
  import PySide6.QtOpenGLWidgets as _QtOpenGL
10
- import PySide6.QtWebEngineCore as _QtWeb
11
- import PySide6.QtWebEngineWidgets as _QtWebW
12
- import PySide6.QtSvg as _QtSvg
13
9
  import numpy as np
14
10
  #######################################################################################
15
11
 
@@ -1468,6 +1464,8 @@ class Drawable:
1468
1464
  self._originalWidth = 0 # initial dimensions, used for rebuilding shapes
1469
1465
  self._originalHeight = 0 # ...
1470
1466
  self._rotation = 0 # ...
1467
+ self._anchorX = None # ... rotation origin, defaults to center if not set
1468
+ self._anchorY = None # ...
1471
1469
  self._toolTipText = None # text to Display on mouse over (None == disabled)
1472
1470
 
1473
1471
  def __str__(self):
@@ -1598,19 +1596,68 @@ class Drawable:
1598
1596
  """
1599
1597
  return int(self._rotation)
1600
1598
 
1601
- def setRotation(self, rotation):
1599
+ def setRotation(self, rotation, anchorX=None, anchorY=None):
1602
1600
  """
1603
1601
  Sets the item's rotation (in degrees).
1604
1602
  Rotation starts at 3 o'clock, increasing counter-clockwise.
1605
- Items rotate around their x, y position.
1603
+ Items rotate around their center by default,
1604
+ or around the origin, if specified.
1606
1605
  """
1607
- oldRotation = self.getRotation()
1608
- rotationDelta = rotation - oldRotation
1609
- if (int(rotationDelta) != 0): # skip if no change
1610
- self._rotation = rotation
1611
- qtRotation = -rotation % 360 # reverse increasing direction (CCW -> clockwise)
1612
- self._qObject.prepareGeometryChange() # invalidate Qt hitbox
1613
- self._qObject.setRotation(qtRotation) # update rotation
1606
+ if anchorX is not None:
1607
+ # anchorX is specified, so remember it for later
1608
+ self._anchorX = anchorX
1609
+ else:
1610
+ # anchorX isn't specified, so use existing anchor
1611
+ if self._anchorX is not None:
1612
+ # anchorX has been set before, so use it
1613
+ anchorX = self._anchorX
1614
+ else:
1615
+ # anchorX hasn't been set yet, so default to centerX
1616
+ anchorX = self.getWidth() / 2
1617
+
1618
+ if anchorY is not None:
1619
+ # anchorY is specified, so remember it for later
1620
+ self._anchorY = anchorY
1621
+ else:
1622
+ # anchorY isn't specified, so use existing anchor
1623
+ if self._anchorY is not None:
1624
+ # anchorY has been set before, so use it
1625
+ anchorY = self._anchorY
1626
+ else:
1627
+ # anchorY hasn't been set yet, so default to centerY
1628
+ anchorY = self.getHeight() / 2
1629
+
1630
+ self._rotation = rotation # remember rotation
1631
+ qtRotation = -rotation % 360 # reverse increasing direction (CCW -> clockwise)
1632
+ self._qObject.setTransformOriginPoint(anchorX, anchorY)
1633
+ self._qObject.prepareGeometryChange() # invalidate Qt hitbox
1634
+ self._qObject.setRotation(qtRotation) # update rotation
1635
+
1636
+ def getAnchor(self):
1637
+ """
1638
+ Returns the item's (x, y) anchor position (in pixels).
1639
+ Rotations are centered around the anchor, relative to the item's position.
1640
+ """
1641
+ anchorX = self._anchorX
1642
+ anchorY = self._anchorY
1643
+
1644
+ # default to center coordinates
1645
+ if anchorX is None:
1646
+ anchorX = self.getWidth() / 2
1647
+ if anchorY is None:
1648
+ anchorY = self.getHeight() / 2
1649
+
1650
+ return (int(anchorX), int(anchorY))
1651
+
1652
+ def setAnchor(self, anchorX, anchorY):
1653
+ """
1654
+ Sets the item's (x, y) anchor position (in pixels).
1655
+ Rotations are centered around the anchor, relative to the item's position.
1656
+ Setting either anchor to None defaults to the center.
1657
+ """
1658
+ self._anchorX = anchorX
1659
+ self._anchorY = anchorY
1660
+ # we don't need to alter the Qt object until the rotation changes
1614
1661
 
1615
1662
  ##### CONVENIENCE METHODS
1616
1663
  # These methods are aliases for the methods above,
@@ -1674,11 +1721,11 @@ class Drawable:
1674
1721
  x, y = self.getPosition()
1675
1722
  self.setPosition(x + dx, y + dy)
1676
1723
 
1677
- def rotate(self, angle):
1724
+ def rotate(self, angle, anchorX=None, anchorY=None):
1678
1725
  """
1679
1726
  Rotates the item by the given angle (in degrees).
1680
1727
  """
1681
- self.setRotation(self.getRotation() + angle)
1728
+ self.setRotation(self.getRotation() + angle, anchorX, anchorY)
1682
1729
 
1683
1730
  ##### LOCATION TESTS
1684
1731
  # These methods help with hit testing and location detection.
@@ -2209,12 +2256,18 @@ class Control(Drawable, Interactable):
2209
2256
  self._width = width
2210
2257
  self._height = height
2211
2258
 
2212
- def setRotation(self, rotation):
2259
+ def setRotation(self, rotation, anchorX=None, anchorY=None):
2213
2260
  """
2214
2261
  Controls cannot be rotated.
2215
2262
  """
2216
2263
  print(f"{type(self).__name__}.setRotation(): Controls cannot be rotated.")
2217
2264
 
2265
+ def setAnchor(self, anchorX, anchorY):
2266
+ """
2267
+ Controls cannot be rotated, so anchors cannot be set.
2268
+ """
2269
+ print(f"{type(self).__name__}.setAnchor(): Controls cannot be rotated, so anchors cannot be set.")
2270
+
2218
2271
 
2219
2272
  #######################################################################################
2220
2273
  # Graphics Objects (Geometric shapes, text, and images)
@@ -2893,41 +2946,11 @@ class Icon(Graphics):
2893
2946
  Read more: https://doc.qt.io/qt-6/qpixmap.html#reading-and-writing-image-files
2894
2947
  """
2895
2948
  Graphics.__init__(self)
2949
+ from pathlib import Path
2950
+ path = Path(filename)
2896
2951
 
2897
2952
  # initialize internal shape
2898
2953
  try:
2899
- # if filename.lower().endswith(".svg"): # rasterize SVG file
2900
- # # render with QWebEngine... (fails to launch web event loop)
2901
- # view = _QtWebW.QWebEngineView()
2902
- # with open(filename, "r", encoding="utf-8") as f:
2903
- # svg = f.read()
2904
-
2905
- # html = f"""
2906
- # <!DOCTYPE html>
2907
- # <html>
2908
- # <body style="margin:0; padding:0; overflow:hidden;">
2909
- # {svg}
2910
- # </body>
2911
- # </html>
2912
- # """
2913
- # view.setHtml(html, _QtCore.QUrl.fromLocalFile(filename))
2914
- # view.show()
2915
- # size = _QtCore.QSize(600, 400)
2916
- # pixmap = _QtGui.QPixmap(size)
2917
- # pixmap.fill(_QtGui.QColorConstants.Transparent)
2918
-
2919
- # # render with SVG Renderer... (only supports Tiny 1.2)
2920
- # renderer = _QtSvg.QSvgRenderer(filename)
2921
- # size = renderer.defaultSize()
2922
- # pixmap = _QtGui.QPixmap(size)
2923
- # pixmap.fill(_QtGui.QColorConstants.Transparent)
2924
- # painter = _QtGui.QPainter(pixmap)
2925
- # renderer.render(painter)
2926
- # painter.end()
2927
-
2928
- # else:
2929
- # pixmap = _QtGui.QPixmap(filename) # create pixmap from file
2930
-
2931
2954
  pixmap = _QtGui.QPixmap(filename) # create pixmap from file
2932
2955
 
2933
2956
  if width is None and height is None: # no scaling needed
@@ -2940,7 +2963,7 @@ class Icon(Graphics):
2940
2963
 
2941
2964
  scaledPixmap = pixmap.scaled(width, height) # scale new pixmap
2942
2965
 
2943
- except: # ... create blank pixmap if import fails (used intentionally in Image)
2966
+ except: # ... create blank pixmap if file fails to load
2944
2967
  if width is None:
2945
2968
  width = 600
2946
2969
  if height is None:
@@ -3184,7 +3207,7 @@ class Label(Graphics):
3184
3207
  """
3185
3208
  return self.getColor()
3186
3209
 
3187
- def setForegroundColor(self, color):
3210
+ def setForegroundColor(self, color=None):
3188
3211
  """
3189
3212
  Sets the label's font color.
3190
3213
  If no color is provided, a color selection box will appear.
@@ -3198,13 +3221,19 @@ class Label(Graphics):
3198
3221
  r, g, b, a = self._backgroundColor
3199
3222
  return Color(r, g, b, a)
3200
3223
 
3201
- def setBackgroundColor(self, color):
3224
+ def setBackgroundColor(self, color=None):
3202
3225
  """
3203
3226
  Sets the label's background color.
3204
3227
  If no color is provided, a color selection box will appear.
3205
3228
  """
3206
- if color is None: # choose a color
3207
- pass # TODO: add color selection box
3229
+ if color is None:
3230
+ # open color selector if no value provided
3231
+ r, g, b = _selectColor()
3232
+ color = Color(r, g, b)
3233
+
3234
+ elif not isinstance(color, Color):
3235
+ # throw error if wrong data type entered
3236
+ raise TypeError(f'{type(self).__name__}.setBackgroundColor(): color should be a Color object (it was {type(color).__name__})')
3208
3237
 
3209
3238
  r, g, b, a = color.getRGBA()
3210
3239
  self._backgroundColor = [r, g, b, a]
@@ -56,7 +56,6 @@ class Image():
56
56
  def _fromPNGBytes(data):
57
57
  """
58
58
  Returns a new Image object built from raw PNG data.
59
- Used by music.View to render images from Verovio.
60
59
  """
61
60
  icon = Icon._fromPNGBytes(data) # generate icon from data
62
61
  width, height = icon.getSize() # get icon dimensions
@@ -21,7 +21,8 @@
21
21
 
22
22
  import mido # provides MIDI input/output and message handling
23
23
  from timer import Timer2 # for scheduling MIDI events
24
- from gui import Display, DropDownList, Color # gui elements for MIDI device selection and display
24
+ # from gui import Display, DropDownList, Color # gui elements for MIDI device selection and display
25
+ # gui elements are lazy-loaded only when device selection GUI is needed
25
26
  import PySide6.QtCore as _QtCore # Qt core utilities for event handling and integration
26
27
  from music import freqToNote # converts frequency values to MIDI note numbers
27
28
  import atexit # ensures cleanup of MIDI resources on program exit
@@ -167,6 +168,8 @@ class MidiIn(_QtCore.QObject):
167
168
  items.sort()
168
169
 
169
170
  if len(items) > 0: # if availabale inputs exist
171
+ from gui import Display, DropDownList, Color
172
+
170
173
  # create selection display
171
174
  self.display = Display("Select MIDI Input", 400, 125) # display info to user
172
175
  self.display.drawLabel('Select a MIDI input device from the list', 45, 30)
@@ -916,6 +919,8 @@ class MidiOut:
916
919
  items.sort()
917
920
 
918
921
  if len(items) > 0: # if available outputs exist
922
+ from gui import Display, DropDownList, Color
923
+
919
924
  # create selection display
920
925
  self.display = Display("Select MIDI Output", 400, 125) # display info to user
921
926
  self.display.drawLabel('Select a MIDI output device from the list', 45, 30)
@@ -3318,25 +3318,34 @@ class Mod():
3318
3318
  raise TypeError(f"Unrecognized material type {type(material)} - expected Phrase, Part, or Score.")
3319
3319
 
3320
3320
  @staticmethod
3321
- def invert(phrase, pitchAxis):
3321
+ def invert(phrase, pitchAxis, scale=CHROMATIC_SCALE, key=0):
3322
3322
  """
3323
3323
  Invert phrase using pitch as the mirror (pivot) axis.
3324
3324
  phrase: Phrase to invert
3325
3325
  pitchAxis: Pitch axis to pivot around (0-127)
3326
+ scale:
3326
3327
  """
3327
3328
  # do some basic error checking
3328
3329
  if type(pitchAxis) is not int:
3329
3330
  raise TypeError(f"Unrecognized pitchAxis type {type(pitchAxis)} - expected int.")
3330
3331
  if type(phrase) is not Phrase:
3331
3332
  raise TypeError(f"Unrecognized material type {type(phrase)} - expected Phrase.")
3333
+
3332
3334
  # traverse list of notes, and adjust pitches accordingly
3335
+ smallestDuration = None
3336
+
3333
3337
  for note in phrase.getNoteList():
3334
3338
 
3335
3339
  if not note.isRest(): # modify regular notes only (i.e., do not modify rests)
3336
3340
  invertedPitch = pitchAxis + (pitchAxis - note.getPitch()) # find mirror pitch around axis (by adding difference)
3337
3341
  note.setPitch(invertedPitch) # and update it
3338
3342
 
3339
- # now, all notes have been updated
3343
+ if (smallestDuration is None) or (note.getDuration() < smallestDuration):
3344
+ # track the smallest non-REST duration in the phrase
3345
+ smallestDuration = note.getDuration()
3346
+
3347
+ # now, all notes have been updated. Next, quantize to filter into the given scale
3348
+ Mod.quantize(phrase, smallestDuration, scale, key)
3340
3349
 
3341
3350
  @staticmethod
3342
3351
  def merge(material1, material2):
@@ -3475,19 +3484,32 @@ class Mod():
3475
3484
  to fit multiples of quantum (e.g., 1.0), using the specified scale (e.g., MAJOR_SCALE),
3476
3485
  and the specified tonic (0 means C, 1 means C sharp, 2 means D, and so on).
3477
3486
  """
3478
-
3479
3487
  # define helper functions
3480
3488
  def quantizeNote(note):
3481
3489
  """Helper function to quantize a note."""
3482
- if note.getPitch() != REST: # ignore rests
3483
- interval = note.getPitch() % 12
3484
- while interval not in scale: # if pitch is not in the scale...
3485
- interval -= 1 # lower pitch by one semitone
3486
-
3490
+ if note.getPitch() != REST: # ignore rests
3487
3491
  # calculate new duration as a multiple of quantum
3488
3492
  newDuration = round(note.getDuration() / quantum) * quantum
3489
3493
  note.setDuration(newDuration)
3490
3494
 
3495
+ # coerce new pitch to given scale
3496
+ noteInterval = note.getPitch() % 12 # interval from base key
3497
+ noteKey = note.getPitch() - noteInterval # base key
3498
+ i = len(scale) - 1
3499
+
3500
+ while i >= 0: # check each interval in scale, back to front
3501
+ scaleInterval = scale[i]
3502
+ if noteInterval >= scaleInterval:
3503
+ # set noteInterval to first scaleInterval it exceeds
3504
+ noteInterval = scaleInterval
3505
+ i = -1 # break
3506
+ else:
3507
+ i = i - 1
3508
+
3509
+ # adjust pitch accordingly
3510
+ newPitch = noteKey + noteInterval
3511
+ note.setPitch(newPitch)
3512
+
3491
3513
  def quantizePhrase(phrase):
3492
3514
  """Helper function to quantize a phrase."""
3493
3515
  for note in phrase.getNoteList():
@@ -3503,25 +3525,25 @@ class Mod():
3503
3525
  for part in score.getPartList():
3504
3526
  quantizePart(part)
3505
3527
 
3506
- # check type of steps
3507
- if type(quantum) is not int:
3508
- raise TypeError( "Unrecognized quantum type " + str(type(quantum)) + " - expected int." )
3528
+ # check type of steps
3529
+ if type(quantum) not in (int, float):
3530
+ raise TypeError( "Unrecognized quantum type " + str(type(quantum)) + " - expected a number." )
3509
3531
 
3510
- # check type of material and execute the appropriate code
3511
- if isinstance(material, Score):
3512
- quantizeScore(material)
3532
+ # check type of material and execute the appropriate code
3533
+ if isinstance(material, Score):
3534
+ quantizeScore(material)
3513
3535
 
3514
- elif isinstance(material, Part):
3515
- quantizePart(material)
3536
+ elif isinstance(material, Part):
3537
+ quantizePart(material)
3516
3538
 
3517
- elif isinstance(material, Phrase):
3518
- quantizePhrase(material)
3539
+ elif isinstance(material, Phrase):
3540
+ quantizePhrase(material)
3519
3541
 
3520
- elif isinstance(material, Note):
3521
- quantizeNote(material)
3542
+ elif isinstance(material, Note):
3543
+ quantizeNote(material)
3522
3544
 
3523
- else: # error check
3524
- raise TypeError( "Unrecognized material type " + str(type(material)) + " - expected Note, Phrase, Part, or Score." )
3545
+ else: # error check
3546
+ raise TypeError( "Unrecognized material type " + str(type(material)) + " - expected Note, Phrase, Part, or Score." )
3525
3547
 
3526
3548
  @staticmethod
3527
3549
  def randomize(material, pitchAmount, durationAmount=0, volumeAmount=0):
@@ -6404,9 +6426,10 @@ class View:
6404
6426
  print(f'View.sketch(): Unrecognized type {type(material)}, expected Score, Part, or Phrase.')
6405
6427
 
6406
6428
  @staticmethod
6407
- def notate(material):
6429
+ def notate(material, writeToFile=False):
6408
6430
  """
6409
6431
  Visualize music as staff notation, where material may be a Score, Part, or Phrase.
6432
+ Setting writeToFile to True saves the visualization as an SVG file.
6410
6433
  """
6411
6434
  # import internal notation renderer
6412
6435
  from CreativePython.notationRenderer import _showNotation
@@ -6424,7 +6447,7 @@ class View:
6424
6447
  title = material.getTitle() if material.getTitle() else "Sheet Music"
6425
6448
 
6426
6449
  # delegate to internal renderer
6427
- _showNotation(material, title)
6450
+ _showNotation(material, title, writeToFile)
6428
6451
 
6429
6452
  else:
6430
6453
  print("View.notate: material must be a Score, Part, Phrase, or Note.")
@@ -18,15 +18,7 @@
18
18
  #
19
19
  ###############################################################################
20
20
 
21
- # Timer (uses Qt's timer, intended for endusers to avoid gui conflicts)
22
- from PySide6.QtWidgets import QApplication
23
- from PySide6.QtCore import QTimer
24
- from PySide6 import QtCore as _QtCore
25
-
26
- # Timer2 (a custom implementation, used internally)
27
- import threading
28
- import time
29
- import atexit
21
+ import threading, time, atexit
30
22
 
31
23
  ##############################################################################
32
24
  # ensure a QApplication exists (needed for QTimer in Timer class)
@@ -38,6 +30,8 @@ def _ensureApp():
38
30
  # this function is called whenever we create a new display,
39
31
  # or queue a function that modifies the display (or the display's items)
40
32
  global _QTAPP_
33
+ from PySide6.QtWidgets import QApplication
34
+
41
35
  if _QTAPP_ is None:
42
36
  # try to find an existing QApplication instance
43
37
  _QTAPP_ = QApplication.instance()
@@ -52,9 +46,6 @@ def _ensureApp():
52
46
  }
53
47
  """)
54
48
 
55
- _ensureApp()
56
-
57
-
58
49
  ###############################################################################
59
50
  # Timer
60
51
  #
@@ -90,6 +81,13 @@ class Timer():
90
81
  def __init__ (self, timeInterval, function, parameters=[], repeat=True):
91
82
  """Specify time interval (in milliseconds), which function to call when the time interval has passed
92
83
  and the parameters to pass this function, and whether to repeat (True) or do it only once."""
84
+ # lazy import Qt modules only when Timer is instantiated
85
+ from PySide6.QtCore import QTimer
86
+ from PySide6 import QtCore as _QtCore
87
+
88
+ # ensure QApplication exists before creating QTimer
89
+ _ensureApp()
90
+
93
91
  # create an internal QTimer
94
92
  self._timer = QTimer() # default QTimer() is a CoarseTimer
95
93
  self._timer.setTimerType(_QtCore.Qt.TimerType.PreciseTimer) # use PreciseTimer for smoother animations
@@ -58,8 +58,8 @@
58
58
  # version 1.0 (May 10, 2003)
59
59
  #
60
60
 
61
- from CreativePython.nevmuse import *
62
- from math import log, sqrt # for logarithmic calculations
61
+ from CreativePython.nevmuse import * # Zipf Metrics
62
+ from math import log, sqrt # logarithmic calculations
63
63
 
64
64
 
65
65
  def byRank(counts):
@@ -251,16 +251,93 @@ def measureMidi(files, metrics, quantum=0.25):
251
251
  return measureScore(scores, metrics, quantum)
252
252
 
253
253
 
254
+ def measureDataByRank(datasets, metricName="ByRank"):
255
+ """
256
+ Calculates Zipf rank-frequency metrics for raw data.
257
+
258
+ datasets: list of lists - each inner list is a list of numeric frequency values
259
+ Example: [[10, 5, 3], [20, 15, 10], ...]
260
+ metricName: string - name for the metric in CSV headers (default: "ByRank")
261
+
262
+ Returns a list of lists - each inner list contains [SimpleMeasurement]
263
+ compatible with writeCSV()
264
+ """
265
+ # validate datasets is a list
266
+ if not isinstance(datasets, list):
267
+ raise TypeError("datasets must be a list")
268
+
269
+ if len(datasets) == 0:
270
+ raise TypeError("datasets must be a non-empty list")
271
+
272
+ allMeasurements = []
273
+
274
+ for counts in datasets:
275
+ # call byRank to get slope, r2, yint
276
+ slope, r2, yint = byRank(counts)
277
+
278
+ # create SimpleMeasurement object
279
+ measurement = _SimpleMeasurement(metricName, slope, r2, yint)
280
+
281
+ # append [measurement] to results
282
+ allMeasurements.append([measurement])
283
+
284
+ return allMeasurements
285
+
286
+
287
+ def measureDataBySize(datasets, metricName="BySize"):
288
+ """
289
+ Calculates Zipf size-frequency metrics for raw data.
290
+
291
+ datasets: list of [sizes, counts] pairs
292
+ Each element is [sizes, counts] where:
293
+ sizes: list of numeric values (x-axis)
294
+ counts: list of numeric values (y-axis)
295
+ Example: [[[1, 2, 3], [10, 8, 5]], [[5, 10, 15], [20, 15, 10]], ...]
296
+ metricName: string - name for the metric in CSV headers (default: "BySize")
297
+
298
+ Returns a list of lists - each inner list contains [SimpleMeasurement]
299
+ compatible with writeCSV()
300
+ """
301
+ # validate datasets is a list
302
+ if not isinstance(datasets, list):
303
+ raise TypeError("datasets must be a list")
304
+
305
+ if len(datasets) == 0:
306
+ raise TypeError("datasets must be a non-empty list")
307
+
308
+ allMeasurements = []
309
+
310
+ for dataset in datasets:
311
+ # unpack each dataset
312
+ try:
313
+ sizes, counts = dataset
314
+ except (ValueError, TypeError):
315
+ raise TypeError("each dataset must be a list [sizes, counts]")
316
+
317
+ # call bySize to get slope, r2, yint
318
+ slope, r2, yint = bySize(sizes, counts)
319
+
320
+ # create SimpleMeasurement object
321
+ measurement = _SimpleMeasurement(metricName, slope, r2, yint)
322
+
323
+ # append [measurement] to results
324
+ allMeasurements.append([measurement])
325
+
326
+ return allMeasurements
327
+
328
+
254
329
  def writeCSV(measurements, filename):
255
330
  """
256
331
  Writes Zipf metric results to a CSV file.
257
- Measurements should come from measureScore or measureMidi.
332
+ Measurements should come from measureScore, measureMidi, measureDataByRank, or measureDataBySize.
258
333
 
259
- measurements: list of lists - results from measureMidi/measureScore
260
- each inner list contains [Measurement1, Measurement2, ..., filename]
334
+ measurements: list of lists - results from measure functions
335
+ Format 1 (with labels): [Measurement1, Measurement2, ..., label]
336
+ Format 2 (no labels): [Measurement1, Measurement2, ...]
261
337
  filename: string - output CSV file path
262
338
 
263
- Creates a CSV with columns: metric values..., Index, Filename
339
+ Creates a CSV with columns: metric values..., Index
340
+ Or with labels: metric values..., SourceName, Index
264
341
  """
265
342
  import os
266
343
  import unicodedata
@@ -273,13 +350,23 @@ def writeCSV(measurements, filename):
273
350
  if not isinstance(measurements[0], (list, tuple)):
274
351
  raise TypeError("measurements must be a list of lists (from measureMidi/measureScore)")
275
352
 
276
- # extract filenames from last element of each inner list
353
+ # check if the last element is a label (string) or a Measurement object
354
+ # to determine if we have source names
355
+ lastElement = measurements[0][-1]
356
+ hasSourceNames = isinstance(lastElement, str)
357
+
358
+ # extract measurements and optional names
277
359
  measurementsList = []
278
360
  names = []
279
- for measurementRow in measurements:
280
- # last element is the filename, everything else is Measurement objects
281
- measurementsList.append(measurementRow[:-1])
282
- names.append(measurementRow[-1])
361
+ if hasSourceNames:
362
+ for measurementRow in measurements:
363
+ # last element is the name/label, everything else is Measurement objects
364
+ measurementsList.append(measurementRow[:-1])
365
+ names.append(measurementRow[-1])
366
+ else:
367
+ # all elements are Measurement objects
368
+ for measurementRow in measurements:
369
+ measurementsList.append(measurementRow)
283
370
 
284
371
  # helper function to convert to ASCII (from advMetricRunner.jythonSurvey.py)
285
372
  def convertToASCII(string):
@@ -306,9 +393,10 @@ def writeCSV(measurements, filename):
306
393
  for key in keys:
307
394
  csvfile.write(key + ",")
308
395
 
309
- # add final headers (Index and Filename columns)
310
- csvfile.write("Index,")
311
- csvfile.write("Filename") # no trailing comma, no newline yet
396
+ # add final headers (SourceName and/or Index columns)
397
+ if hasSourceNames:
398
+ csvfile.write("SourceName,")
399
+ csvfile.write("Index") # no trailing comma, no newline yet
312
400
 
313
401
  # write data rows
314
402
  for i, measurements in enumerate(measurementsList):
@@ -320,16 +408,43 @@ def writeCSV(measurements, filename):
320
408
  for value in values:
321
409
  csvfile.write(convertToASCII(value) + ",")
322
410
 
323
- # write index (1-based, like advMetricRunner)
411
+ # write source name if present
412
+ if hasSourceNames:
413
+ sourceName = names[i]
414
+ if os.path.sep in sourceName:
415
+ sourceName = os.path.basename(sourceName)
416
+ sourceName = convertToASCII(sourceName)
417
+ csvfile.write(sourceName + ",")
418
+
419
+ # write index (1-based, last column, no trailing comma or newline)
324
420
  index = i + 1
325
- csvfile.write(convertToASCII(index) + ",")
421
+ csvfile.write(convertToASCII(index))
422
+
326
423
 
327
- # write filename (last column, no trailing comma or newline)
328
- sourceName = names[i]
329
- if os.path.sep in sourceName:
330
- sourceName = os.path.basename(sourceName)
331
- sourceName = convertToASCII(sourceName)
332
- csvfile.write(sourceName)
424
+ class _SimpleMeasurement:
425
+ """
426
+ Simple measurement object for raw Zipf analysis.
427
+ Compatible with writeCSV() but produces cleaner output than standard Measurement class.
428
+ Only includes Slope, R2, and Y-Intercept columns (no LocalVariability fields).
429
+ """
430
+
431
+ def __init__(self, metricName, slope, r2, yint):
432
+ self.metricName = metricName
433
+ self.slope = slope
434
+ self.r2 = r2
435
+ self.yint = yint
436
+
437
+ def keysToArray(self):
438
+ """Returns column headers for CSV output"""
439
+ return [
440
+ f"{self.metricName}_Slope",
441
+ f"{self.metricName}_R2",
442
+ f"{self.metricName}_YInt"
443
+ ]
444
+
445
+ def valuesToArray(self):
446
+ """Returns values for CSV output"""
447
+ return [self.slope, self.r2, self.yint]
333
448
 
334
449
 
335
450
  if __name__ == '__main__':
@@ -364,3 +479,42 @@ if __name__ == '__main__':
364
479
  sizes = histogram.keys()
365
480
  slope, r2, yint = bySize(sizes, counts)
366
481
  print(f"The bySize slope is {slope} and the R^2 is {r2}")
482
+
483
+ # Test measureDataByRank
484
+ print("\n=== Testing measureDataByRank ===")
485
+ rankDatasets = [
486
+ [10, 5, 5, 3, 3, 3],
487
+ [20, 15, 10, 5],
488
+ [100, 50, 33, 25, 20]
489
+ ]
490
+ rankResults = measureDataByRank(rankDatasets)
491
+ print(f"Results: {len(rankResults)} datasets analyzed")
492
+ for i, row in enumerate(rankResults, 1):
493
+ measurement = row[0]
494
+ print(f" Dataset {i}: slope={measurement.slope:.3f}, r2={measurement.r2:.3f}, yint={measurement.yint:.3f}")
495
+
496
+ # Test measureDataBySize
497
+ print("\n=== Testing measureDataBySize ===")
498
+ sizeDatasets = [
499
+ [[1, 2, 3, 4], [10, 8, 5, 2]],
500
+ [[5, 10, 15], [20, 15, 10]]
501
+ ]
502
+ sizeResults = measureDataBySize(sizeDatasets)
503
+ print(f"Results: {len(sizeResults)} datasets analyzed")
504
+ for i, row in enumerate(sizeResults, 1):
505
+ measurement = row[0]
506
+ print(f" Dataset {i}: slope={measurement.slope:.3f}, r2={measurement.r2:.3f}, yint={measurement.yint:.3f}")
507
+
508
+ # Test CSV compatibility - separate files for different metric types
509
+ print("\n=== Testing CSV output ===")
510
+ writeCSV(rankResults, "test_zipf_rank.csv")
511
+ print("Rank CSV file created successfully")
512
+
513
+ writeCSV(sizeResults, "test_zipf_size.csv")
514
+ print("Size CSV file created successfully")
515
+
516
+ # Test custom metric names
517
+ print("\n=== Testing custom metric names ===")
518
+ customResults = measureDataByRank(rankDatasets, metricName="CustomFreq")
519
+ writeCSV(customResults, "test_zipf_custom.csv")
520
+ print("Custom metric CSV file created successfully")
File without changes
File without changes
File without changes