TweetyPy

Check-in [ec4628377a]

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Overview
Comment:basic logging implemented ... need better file location
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: ec4628377a3572a266de05667cdf192d36814da2d713646bf47c9a55eed2d269
User & Date: jmcclure 2019-09-08 17:33:55
Context
2019-09-08
18:56
log warning/error in status bar check-in: 88afc776d3 user: jmcclure tags: trunk
17:33
basic logging implemented ... need better file location check-in: ec4628377a user: jmcclure tags: trunk
00:12
Windows install simplification check-in: 7487060220 user: jmcclure tags: trunk
Changes

Changes to tweetypy/__init__.py.

1
2
3

4
5
6
7
8
9

10
11
12
13





14
15
16
17


18

19
20
21
22
23

24
25
26
27
28
29
30
31

import os, sys, time
from PyQt5.QtWidgets import QApplication

from tweetypy.config import configure, flush_queue
from tweetypy.mainwin import MainWin

def main():
	config = configure()
	try:

		if config['data-files']['log-file']:
			sys.stdout = open(config['data-files']['log-file'], 'w')
	except KeyError:
		pass





	print(f' INFO: TweetyPy started on {time.asctime(time.localtime())}')
	print(' INFO: Version 1.0.0 "Ain\'t She Tweet"')
	print(' INFO: Copyright 2017 Jesse McClure')
	print(' INFO: License: MIT')


	app = QApplication(sys.argv)

	args = sys.argv[1:]
	files = [arg for arg in args if os.path.isfile(arg)]
	flags = [arg for arg in args if arg[0] == '-']
	other = [arg for arg in args if arg not in files + flags]
	# TODO create logger

	if flags:
		print(' WARN: ignoring unrecognized flags: ', flags)
	if other:
		print(' WARN: ignoring unrecognized arguments: ', other)
	flush_queue()
	win = MainWin(files, config)
	return app.exec_()


|

>
|



<
<
>
|
|
|
|
>
>
>
>
>
|
|
|
|
>
>
|
>
|
|
|
|
<
>
|
|
|
|
<
|
|

1
2
3
4
5
6
7
8


9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

31
32
33
34
35

36
37
38

import os, sys, time, logging
from PyQt5.QtWidgets import QApplication
from logging.handlers import RotatingFileHandler
from tweetypy.config import configure
from tweetypy.mainwin import MainWin

def main():


   logging.basicConfig(
      level=logging.INFO,
      format='%(created)f <%(levelno)s> %(module)12s(%(lineno)3d): %(message)s',
      handlers = [
         logging.StreamHandler(),
         RotatingFileHandler('tweetypy.log', maxBytes=(4 * 1024 * 1024), backupCount=255)
      ]
   )
   log = logging.getLogger()
   log.info('---------------------------------------------')
   log.info(f'TweetyPy started on {time.asctime(time.localtime())}')
   log.info('Version 1.0.0 "Ain\'t She Tweet"')
   log.info('Copyright 2017 Jesse McClure')
   log.info('License: MIT')
   log.info('---------------------------------------------')
   config = configure()
   app = QApplication(sys.argv)

   args = sys.argv[1:]
   files = [arg for arg in args if os.path.isfile(arg)]
   flags = [arg for arg in args if arg[0] == '-']
   other = [arg for arg in args if arg not in files + flags]


   if flags:
      log.warning(f'Ignoring unrecognized flags: {flags}')
   if other:
      log.warning(f'Ignoring unrecognized arguments: {other}')

   win = MainWin(files, config)
   return app.exec_()

Changes to tweetypy/config.py.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
..
64
65
66
67
68
69
70
71
72
73
74
75
76

77
78
79
80
81
82

import os, site, tempfile, yaml
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPalette, QColor

_queue = []

def configure():
	files = []
	for fname in [ 'icons.yaml', 'actions.yaml', 'menu.yaml', 'config.yaml' ]:
		files += [os.path.join(p, 'tweetypy', fname) for p in site.getsitepackages()]
		if 'XDG_CONFIG_HOME' in os.environ:
			files += [os.path.join(os.environ['XDG_CONFIG_HOME'], 'tweetypy', fname)]
		elif 'HOME' in os.environ:
			files += [os.path.join(os.environ['HOME'], '.config', 'tweetypy', fname)]
		elif 'APPDATA' in os.environ:
			files += os.path.join(os.environ['APPDATA'], 'tweetypy', fname)
		elif 'USERPROFILE' in os.enviorn:
			files += os.path.join(os.environ['USERPROFILE'], 'AppData', 'tweetypy', fname)
		files += [os.path.join(os.getcwd(), fname)]


	# Color constants
	Window = QColor(QPalette.Window).name()
	Background = QColor(QPalette.Background).name()
	WindowText = QColor(QPalette.WindowText).name()
	Foreground = QColor(QPalette.Foreground).name()
	Base = QColor(QPalette.Base).name()
	AlternateBase = QColor(QPalette.AlternateBase).name()
	ToolTipBase = QColor(QPalette.ToolTipBase).name()
	ToolTipText = QColor(QPalette.ToolTipText).name()
	Text = QColor(QPalette.Text).name()
	Button = QColor(QPalette.Button).name()
	ButtonText = QColor(QPalette.ButtonText).name()
	BrightText = QColor(QPalette.BrightText).name()
	Highlight =  QColor(QPalette.Highlight).name()
	# Icon style constants
	IconOnly = Qt.ToolButtonIconOnly
	TextOnly = Qt.ToolButtonTextOnly
	TextBesideIcon = Qt.ToolButtonTextBesideIcon
	TextUnderIcon = Qt.ToolButtonTextUnderIcon
	FollowStyle = Qt.ToolButtonFollowStyle
	# Misc constants
	TempFile = os.path.join(tempfile.gettempdir(), 'tweetypy.log')

	conf = f"""
constants:

   Window: &Window {Window}
   Background: &Background {Background}
   WindowText: &WindowText {WindowText}
   Foreground: &Foreground {Foreground}
   Base: &Base {Base}
................................................................................
   TextBesideIcon: &TextBesideIcon {TextBesideIcon}
   TextUnderIcon: &TextUnderIcon {TextUnderIcon}
   FollowStyle: &FollowStyle {FollowStyle}

   TempFile: &TempFile {TempFile}
"""

	for fname in files:
		global _queue
		if (os.path.isfile(fname)):
			with open(fname, 'r') as fin:
				_queue += [f' INFO: read config from {fname}']
				conf += fin.read()

	return yaml.safe_load(conf)

def flush_queue():
	for line in _queue:
		print(line)


|



|


|
|
|
|
|
|
|
|
|
|
|
|
>

|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|

|







 







|
<
|
|
<
|
>
|

<
<
<

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
..
65
66
67
68
69
70
71
72

73
74

75
76
77
78



79

import logging, os, site, tempfile, yaml
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPalette, QColor

log = logging.getLogger()

def configure():
   files = []
   for fname in [ 'icons.yaml', 'actions.yaml', 'menu.yaml', 'config.yaml' ]:
      files += [os.path.join(p, 'tweetypy', fname) for p in site.getsitepackages()]
      if 'XDG_CONFIG_HOME' in os.environ:
         files += [os.path.join(os.environ['XDG_CONFIG_HOME'], 'tweetypy', fname)]
      elif 'HOME' in os.environ:
         files += [os.path.join(os.environ['HOME'], '.config', 'tweetypy', fname)]
      elif 'APPDATA' in os.environ:
         files += os.path.join(os.environ['APPDATA'], 'tweetypy', fname)
      elif 'USERPROFILE' in os.enviorn:
         files += os.path.join(os.environ['USERPROFILE'], 'AppData', 'tweetypy', fname)
      files += [os.path.join(os.getcwd(), fname)]
   log.debug(f'Files: {files}')

   # Color constants
   Window = QColor(QPalette.Window).name()
   Background = QColor(QPalette.Background).name()
   WindowText = QColor(QPalette.WindowText).name()
   Foreground = QColor(QPalette.Foreground).name()
   Base = QColor(QPalette.Base).name()
   AlternateBase = QColor(QPalette.AlternateBase).name()
   ToolTipBase = QColor(QPalette.ToolTipBase).name()
   ToolTipText = QColor(QPalette.ToolTipText).name()
   Text = QColor(QPalette.Text).name()
   Button = QColor(QPalette.Button).name()
   ButtonText = QColor(QPalette.ButtonText).name()
   BrightText = QColor(QPalette.BrightText).name()
   Highlight =  QColor(QPalette.Highlight).name()
   # Icon style constants
   IconOnly = Qt.ToolButtonIconOnly
   TextOnly = Qt.ToolButtonTextOnly
   TextBesideIcon = Qt.ToolButtonTextBesideIcon
   TextUnderIcon = Qt.ToolButtonTextUnderIcon
   FollowStyle = Qt.ToolButtonFollowStyle
   # Misc constants
   TempFile = os.path.join(tempfile.gettempdir(), 'tweetypy.log')

   conf = f"""
constants:

   Window: &Window {Window}
   Background: &Background {Background}
   WindowText: &WindowText {WindowText}
   Foreground: &Foreground {Foreground}
   Base: &Base {Base}
................................................................................
   TextBesideIcon: &TextBesideIcon {TextBesideIcon}
   TextUnderIcon: &TextUnderIcon {TextUnderIcon}
   FollowStyle: &FollowStyle {FollowStyle}

   TempFile: &TempFile {TempFile}
"""

   for fname in files:

      if (os.path.isfile(fname)):
         with open(fname, 'r') as fin:

            conf += fin.read()
            log.info(f'Reading config from {fname}')
   return yaml.safe_load(conf)





Changes to tweetypy/config.yaml.

38
39
40
41
42
43
44
45
46
47
48
        position: top
        visible: true
        style: *TextBesideIcon
   statusbar:
      visible: true

data-files:
   log-file:
   header-format: "Song\tFrequency Excursion\tNotes\n"
   data-format: "{name}\t{fex:.3f}\t{notes}\n"








<



38
39
40
41
42
43
44

45
46
47
        position: top
        visible: true
        style: *TextBesideIcon
   statusbar:
      visible: true

data-files:

   header-format: "Song\tFrequency Excursion\tNotes\n"
   data-format: "{name}\t{fex:.3f}\t{notes}\n"

Changes to tweetypy/mainwin.py.

1


2
3
4
5
6

7








8
9
10

11
12
13
14
15
16
17
18
19
20
21
22
23
24




25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113

114

115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157



from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QIcon, QPalette, QPixmap
from PyQt5.QtWidgets import qApp, QAction, QFileDialog, QLabel, QMenuBar, QMainWindow, QWidget
from tweetypy.spectrogram import Spectrogram











class MainWin(QMainWindow):
	def __init__(self, files, config):
		super().__init__()

		self.files = files
		self.data = []
		self.saved = True
		self.config = config
		#self.setGeometry(0, 0, 800, 600)
		self.setWindowTitle("TweetyPy")
		self.setWindowIcon(QIcon('web.png'))	# TODO FIXME
		if self.config['main-window']['maximized']:
			self.setWindowState(Qt.WindowMaximized)
		self.init_calls()
		self.init_actions()
		self.init_menubar()
		self.init_toolbar()
		self.init_statusbar()




		self.init_body()
		self.show()
		self.spect = None
		# Spectrograms are sized relative to parent window.  The timer allows
		# the window manager to adjust the parent window prior to this sizing.
		if self.files:
			QTimer.singleShot(20,self.next_spectrogram)

	# TODO handle drag & drop of sound files to add to queue
	# and update window title

	def action_unknown(self):
		print(' WARN: unknown action binding')

	def action_add_songs(self):
		fnames, kind = QFileDialog.getOpenFileNames(self, 'Select Audio Files', '', "Audio (*.wav)")
		if not fnames:
			return
		self.files += fnames
		if self.spect:
			self.setWindowTitle(f'TweetyPy: {self.spect.data["name"]} [+{len(self.files)} more]')
		else:
			self.next_spectrogram()

	def action_save(self):
		fname, kind = QFileDialog.getSaveFileName(self, 'Save data file', '', 'Data files (*.txt *.csv)')
		if not fname:
			return
		with open(fname, 'w') as f:
			f.write(self.config['data-files']['header'])
			for data in self.data:
				f.write(self.config['data-files']['format'].format(**data)) # TODO needs testing
		self.saved = True

	def init_calls(self):
		self.calls = {
			'add_songs': self.action_add_songs,
			'fit': lambda: self.spect.fit() if self.spect else None,
			'mode_normal': lambda: self.spect.mode_normal() if self.spect else None,
			'mode_cursor': lambda: self.spect.mode_cursor() if self.spect else None,
			'mode_eraser': lambda: self.spect.mode_eraser() if self.spect else None,
			'next_song': self.next_spectrogram,
			'undo': lambda: self.spect.undo() if self.spect else None,
			#'prev_song':
			'quit': self.close,
			'unknown': self.action_unknown,
		}

	def init_body(self):
		self.body = QWidget()
		self.body.setBackgroundRole(QPalette.AlternateBase)
		self.body.setAutoFillBackground(True)
		self.body.setContentsMargins(10,10,10,10)
		self.setCentralWidget(self.body)

	def init_actions(self):
		self.actions = dict()
		for act in self.config['actions']:
			pix = QPixmap()
			pix.loadFromData(act['icon'])
			a = QAction(QIcon(pix), act['menu'], self)
			a.setShortcut(act['shortcut'])
			a.setStatusTip(act['statustip'])
			if act['name'] in self.calls:
				a.triggered.connect(self.calls[act['name']])
			else:
				a.triggered.connect(self.calls['unknown'])
			self.actions[act['name']] = a

	def init_menubar(self):
		def add_menu(menu, entry):
			if type(entry) is list:
				for e in entry:
					add_menu(menu, e)
			elif type(entry) is dict and 'name' in entry and 'items' in entry:
				sub = menu.addMenu(entry['name'])
				add_menu(sub, entry['items'])
			elif type(entry) is str and entry in self.actions:
				menu.addAction(self.actions[entry])
			elif type(entry) is str and entry == 'bar':
				menu.addSeparator()
		conf = self.config['main-window']
		if 'menu' in conf and type(conf['menu']) is list:
			bar = self.menuBar()
			add_menu(bar, conf['menu'])

	def init_statusbar(self):
		self.statusbar = self.statusBar()
		self.status_text = QLabel()

		self.statusBar().addPermanentWidget(self.status_text)


	def init_toolbar(self):
		for bar in self.config['main-window']['toolbars']:
			toolbar = self.addToolBar(bar['name'])
			toolbar.setVisible(bar['visible'])
			toolbar.setToolButtonStyle(bar['style'])
			for item in bar['items']:
				if item == 'bar':
					toolbar.addSeparator()
				elif item in self.actions:
					toolbar.addAction(self.actions[item])
				else:
					print(f' WARN: unknown toolbar item "{item}"')

	def closeEvent(self, e):
		if self.spect and not self.spect.stored:
			print(' DATA: ' + ' '.join(map(str, self.spect.data.values())))
			self.data.append(self.spect.data)
			self.saved = False
		if not self.saved:
			self.action_save()

	def next_spectrogram(self):
		if self.spect and not self.spect.stored:
			print(' DATA: ' + ' '.join(map(str, self.spect.data.values())))
			self.data.append(self.spect.data)
			self.spect.close()
			self.saved = False
		if self.files:
			self.fname = self.files.pop(0)
			self.spect = Spectrogram(self.fname, self.body, self.config['spectrogram'])
			self.spect.mouseOver.connect(self.update_status)
			title = f'TweetyPy: {self.spect.data["name"]}'
			if self.files:
				title += f' [+{len(self.files)} more]'
			self.setWindowTitle(title)
		else:
			self.fname = None
			self.spect = None

	def update_status(self, x, y):
		self.status_text.setText(f'FEX: {self.spect.data["fex"]:.3}   {x:0.3}s {y:0.1}KHz')


>
>





>

>
>
>
>
>
>
>
>

|
|
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
>
>
>
>
|
|
|
|
|
|
|

|
|

|
|

|
|
|
|
|
|
|
|
|

|
|
|
|
|
|
|
|
|

|
|
|
|
|
|
|
|
|
|
|
|
|

|
|
|
|
|
|

|
|
|
|
|
|
|
|
|
|
|
|
|

|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|

|
|
|
>
|
>

|
|
|
|
|
|
|
|
|
|
|
|

|
|
|
|
|
|
|

|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|

|
|

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175

import logging
from logging import Handler
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QIcon, QPalette, QPixmap
from PyQt5.QtWidgets import qApp, QAction, QFileDialog, QLabel, QMenuBar, QMainWindow, QWidget
from tweetypy.spectrogram import Spectrogram

log = logging.getLogger()

class RequestHandler(Handler):
   def __init__(self, text):
      super().__init__()
      self.text = text

   def emit(self, record):
      self.text.setText(self.format(record))

class MainWin(QMainWindow):
   def __init__(self, files, config):
      super().__init__()

      self.files = files
      self.data = []
      self.saved = True
      self.config = config
      #self.setGeometry(0, 0, 800, 600)
      self.setWindowTitle("TweetyPy")
      self.setWindowIcon(QIcon('web.png'))   # TODO FIXME
      if self.config['main-window']['maximized']:
         self.setWindowState(Qt.WindowMaximized)
      self.init_calls()
      self.init_actions()
      self.init_menubar()
      self.init_toolbar()
      self.init_statusbar()

      self.slog = RequestHandler(self.statusbar.text2)
      log.addHandler(self.slog)

      self.init_body()
      self.show()
      self.spect = None
      # Spectrograms are sized relative to parent window.  The timer allows
      # the window manager to adjust the parent window prior to this sizing.
      if self.files:
         QTimer.singleShot(20,self.next_spectrogram)

   # TODO handle drag & drop of sound files to add to queue
   # and update window title

   def action_unknown(self):
      log.warning('Ignoring unknown action binding')

   def action_add_songs(self):
      fnames, kind = QFileDialog.getOpenFileNames(self, 'Select Audio Files', '', "Audio (*.wav)")
      if not fnames:
         return
      self.files += fnames
      if self.spect:
         self.setWindowTitle(f'TweetyPy: {self.spect.data["name"]} [+{len(self.files)} more]')
      else:
         self.next_spectrogram()

   def action_save(self):
      fname, kind = QFileDialog.getSaveFileName(self, 'Save data file', '', 'Data files (*.txt *.csv)')
      if not fname:
         return
      with open(fname, 'w') as f:
         f.write(self.config['data-files']['header'])
         for data in self.data:
            f.write(self.config['data-files']['format'].format(**data)) # TODO needs testing
      self.saved = True

   def init_calls(self):
      self.calls = {
         'add_songs': self.action_add_songs,
         'fit': lambda: self.spect.fit() if self.spect else None,
         'mode_normal': lambda: self.spect.mode_normal() if self.spect else None,
         'mode_cursor': lambda: self.spect.mode_cursor() if self.spect else None,
         'mode_eraser': lambda: self.spect.mode_eraser() if self.spect else None,
         'next_song': self.next_spectrogram,
         'undo': lambda: self.spect.undo() if self.spect else None,
         #'prev_song':
         'quit': self.close,
         'unknown': self.action_unknown,
      }

   def init_body(self):
      self.body = QWidget()
      self.body.setBackgroundRole(QPalette.AlternateBase)
      self.body.setAutoFillBackground(True)
      self.body.setContentsMargins(10,10,10,10)
      self.setCentralWidget(self.body)

   def init_actions(self):
      self.actions = dict()
      for act in self.config['actions']:
         pix = QPixmap()
         pix.loadFromData(act['icon'])
         a = QAction(QIcon(pix), act['menu'], self)
         a.setShortcut(act['shortcut'])
         a.setStatusTip(act['statustip'])
         if act['name'] in self.calls:
            a.triggered.connect(self.calls[act['name']])
         else:
            a.triggered.connect(self.calls['unknown'])
         self.actions[act['name']] = a

   def init_menubar(self):
      def add_menu(menu, entry):
         if type(entry) is list:
            for e in entry:
               add_menu(menu, e)
         elif type(entry) is dict and 'name' in entry and 'items' in entry:
            sub = menu.addMenu(entry['name'])
            add_menu(sub, entry['items'])
         elif type(entry) is str and entry in self.actions:
            menu.addAction(self.actions[entry])
         elif type(entry) is str and entry == 'bar':
            menu.addSeparator()
      conf = self.config['main-window']
      if 'menu' in conf and type(conf['menu']) is list:
         bar = self.menuBar()
         add_menu(bar, conf['menu'])

   def init_statusbar(self):
      self.statusbar = self.statusBar()
      self.statusbar.text1 = QLabel()
      self.statusbar.text2 = QLabel()
      self.statusBar().addPermanentWidget(self.statusbar.text2)
      self.statusBar().addPermanentWidget(self.statusbar.text1)

   def init_toolbar(self):
      for bar in self.config['main-window']['toolbars']:
         toolbar = self.addToolBar(bar['name'])
         toolbar.setVisible(bar['visible'])
         toolbar.setToolButtonStyle(bar['style'])
         for item in bar['items']:
            if item == 'bar':
               toolbar.addSeparator()
            elif item in self.actions:
               toolbar.addAction(self.actions[item])
            else:
               log.warning(f'Unknown toolbar item "{item}"')

   def closeEvent(self, e):
      if self.spect and not self.spect.stored:
         log.info('fex: ' + ' '.join(map(str, self.spect.data.values())))
         self.data.append(self.spect.data)
         self.saved = False
      if not self.saved:
         self.action_save()

   def next_spectrogram(self):
      if self.spect and not self.spect.stored:
         log.info('fex: ' + ' '.join(map(str, self.spect.data.values())))
         self.data.append(self.spect.data)
         self.spect.close()
         self.saved = False
      if self.files:
         self.fname = self.files.pop(0)
         self.spect = Spectrogram(self.fname, self.body, self.config['spectrogram'])
         self.spect.mouseOver.connect(self.update_status)
         title = f'TweetyPy: {self.spect.data["name"]}'
         if self.files:
            title += f' [+{len(self.files)} more]'
         self.setWindowTitle(title)
      else:
         self.fname = None
         self.spect = None

   def update_status(self, x, y):
      self.statusbar.text1.setText(f'FEX: {self.spect.data["fex"]:.3}   {x:0.3}s {y:0.1}KHz')

Changes to tweetypy/spectrogram.py.

1
2
3
4
5
6
7
8


9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76

77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264

import os, wave
from numpy import *
from scipy.signal import spectrogram
from PyQt5.QtCore import pyqtSignal, Qt, QPoint, QRect
from PyQt5.QtGui import QColor, QImage, QPainter, QPainterPath
from PyQt5.QtWidgets import QGraphicsOpacityEffect, QWidget



class Spectrogram(QWidget):

	mouseOver = pyqtSignal(float, float)

	def __init__(self, fname, parent, config):
		super().__init__(parent)
		print(f' INFO: processing {fname}')
		self.conf = config
		self.setMouseTracking(True)
		self.setGeometry(parent.contentsRect())
		self.setAttribute(Qt.WA_DeleteOnClose)
		self.data = {
			'name': os.path.basename(fname),
			'path': fname,
			'pex': 0.0,
			'tex': 0.0,
			'fex': 0.0,
			'notes': ''}
		self.stored = False
		self.init_fft()
		if self.amp is None:
			parent.next_spectrogram()
			self.close()
			return
		self.init_calculations()
		self.init_image()
		self.fit()
		self.eraser = self.init_eraser(10, 200)
		self.eraser.maxheight = 200
		self.cursorX = self.init_cursor(1, self.height() * 4)
		self.cursorY = self.init_cursor(self.width() * 4, 1)
		self.show()

	def init_fft(self):
		fft = self.conf['fft']
		try:
			with wave.open(self.data['path'], 'rb') as song:
				channels, bs, samprate, nsamp, comp, compnam  = song.getparams()
				data = fromstring(song.readframes(nsamp), dtype=uint16)
		except wave.Error:
			print(f' WARN: skipping \"{self.data["path"]}\": not a wave file.')
			return None
		f, t, amp = spectrogram(data, samprate, fft['window-function'],
				fft['window'], fft['overlap'], detrend=False, mode='psd' )
		self.freq = f
		self.time = t
		self.amp = amp

	def init_calculations(self):
		calc = self.conf['calculations']
		bot = argmax(self.freq > calc['high-pass'])
		top = argmin(self.freq < calc['low-pass'])
		self.freq = self.freq[bot:top]
		self.amp = self.amp[bot:top,:]

		self.thresh = self.amp.max() / 10**(calc['threshold'] / 10.0)
		self.thresh = 255.0 - self.thresh * 255.0 / self.amp.max()
		self.amp = 255.0 - self.amp * 255.0 / self.amp.max()

		arr = asarray(self.amp.flat)
		per = 100 * (arr <= self.thresh).sum() / len(arr)
		msg = f'{per:.2}%% of signal is above the threshold.'
		if per > 35.0:
			print(f' WARN: {msg}  Consider using a lower threshold.')
		elif per < 5.0:
			print(f' WARN: {msg}  Consider using a higher threshold.')
		else:
			print(f' INFO: {msg}')


	def init_image(self):
		colors = self.conf['colors']
		self.amp = flipud(self.amp)
		self.amp_back = copy(self.amp)
		self.freq = flipud(self.freq)
		self.imgdata = array(self.amp, uint8)
		h, w = self.imgdata.shape
		self.freq /= 1000.0
		self.image = QImage(self.imgdata.tobytes(), w, h, w, QImage.Format_Grayscale8)
		if colors['invert']:
			self.image.invertPixels()

	def init_cursor(self, w, h):
		cursor = QWidget(self)
		cursor.setAttribute(Qt.WA_TransparentForMouseEvents)
		effect = QGraphicsOpacityEffect(cursor)
		effect.setOpacity(0.4)
		cursor.setGraphicsEffect(effect)
		cursor.setGeometry(0, 0, w, h)
		cursor.setAutoFillBackground(True)
		p = cursor.palette()
		p.setColor(cursor.backgroundRole(), QColor(self.conf['colors']['cursor']))
		cursor.setPalette(p)
		cursor.hide()
		return cursor

	def init_eraser(self, w, h):
		eraser = QWidget(self)
		eraser.setAttribute(Qt.WA_TransparentForMouseEvents)
		effect = QGraphicsOpacityEffect(eraser)
		effect.setOpacity(0.4)
		eraser.setGraphicsEffect(effect)
		eraser.setGeometry(0, 0, w, h)
		eraser.setAutoFillBackground(True)
		p = eraser.palette()
		p.setColor(eraser.backgroundRole(), QColor(self.conf['colors']['eraser']))
		eraser.setPalette(p)
		eraser.hide()
		return eraser

	def fit(self):
		s = self.image.size()
		asp = s.width() / (s.height() * self.conf['scale-ratio'])
		if self.size().height() * asp > self.size().width():
			self.resize(self.width(), self.width() / asp)
		else:
			self.resize(self.height() * asp, self.height())

	def mode_normal(self):
		self.unsetCursor()
		self.eraser.hide()
		self.cursorX.hide()
		self.cursorY.hide()

	def mode_cursor(self):
		self.setCursor(Qt.BlankCursor)
		self.eraser.hide()
		self.cursorX.show()
		self.cursorY.show()

	def mode_eraser(self):
		self.setCursor(Qt.BlankCursor)
		self.cursorX.hide()
		self.cursorY.hide()
		self.eraser.show()

	def undo(self):
		copyto(self.amp, self.amp_back)
		self.update()

	def paintEvent(self, e):
		# TODO refactor this rats nest
		qp = QPainter(self)
		qp.setRenderHints(QPainter.SmoothPixmapTransform | QPainter.Antialiasing)
		qp.drawImage(self.contentsRect(), self.image, self.image.rect())
		r = QRect(0, 0, self.width() - 1, self.height() - 1)
		qp.setPen(QColor(self.conf['colors']['border']))
		qp.drawRect(r)
		path = QPainterPath()
		pfreq, ptime, pex, tex = 0.0, 0.0, 0.0, 0.0
		qp.setPen(QColor(self.conf['colors']['line']))
		for x, y in enumerate(argmin(self.amp, 0)):
			if self.amp[y,x] > self.thresh:
				continue
			t, f = self.time[x], self.freq[y]
			if self.conf['calculations']['log10-freq']:
				f = log10(f)
			if pfreq == 0.0:
				path.moveTo(x * self.sx, y * self.sy)
			else:
				path.lineTo(x * self.sx, y * self.sy)
				pex += sqrt((f - pfreq)**2 + (t - ptime)**2)
				tex += t - ptime
			ptime, pfreq = t, f
		qp.drawPath(path)
		self.data['pex'] = pex
		self.data['tex'] = tex
		try:
			self.data['fex'] = pex / tex
		except ZeroDivisionError:
			self.data['fex'] = 0.0
		#self.fexChanged.emit() # TODO replace this?

	def resizeEvent(self, e):
		self.sx = self.width() / self.image.width()
		self.sy = self.height() / self.image.height()

	def mousePressEvent(self, e):
		if self.cursorX.isVisible():
			pass
		elif self.eraser.isVisible():
			copyto(self.amp_back, self.amp)
			pass
		elif e.button() == Qt.MiddleButton:
			self.fit()
		else:
			self.__pos = self.pos()
			self.__size = self.size()
			self.__epos = self.mapToParent(e.pos())

	def mouseMoveEvent(self, e):
		# TODO refactor this rats nest
		# TODO add eraser size changes
		y = e.pos().y()
		if y < self.eraser.maxheight / 2.0:
			fixed = y * 2.0
		elif y > self.height() - self.eraser.maxheight / 2.0:
			fixed = (self.height() - y) * 2.0
		else:
			fixed = self.eraser.maxheight
		self.eraser.setFixedHeight(int(clip(fixed,0,self.eraser.maxheight)))
		x = e.pos().x() - self.eraser.width() / 2.0
		y = e.pos().y() - self.eraser.height() / 2.0
		self.eraser.move(x, y)

		x = e.pos().x()
		y = e.pos().y()
		self.cursorX.move(x, 0)
		self.cursorY.move(0, y)
		s = self.imgdata.shape
		x = clip(int(s[1] * e.pos().x() / self.width()), 0, self.time.size)
		y = clip(int(s[0] * e.pos().y() / self.height()), 0, self.freq.size)
		self.mouseOver.emit(self.time[x], self.time[y])
		if e.buttons() and self.cursorX.isVisible():
			self.mouseCursorEvent(e)
		elif e.buttons() and self.eraser.isVisible():
			self.mouseEraseEvent(e)
		elif e.buttons():
			self.mouseNormalEvent(e)

	def mouseCursorEvent(self, e):
		pass

	def mapToImgdata(self, x, y):
		s = self.imgdata.shape
		x *= s[1] / self.width()
		y *= s[0] / self.height()
		x = int(clip(x, 0, s[1]))
		y = int(clip(y, 0, s[0]))
		return x, y

	def mouseEraseEvent(self, e):
		w = self.eraser.width()
		h = self.eraser.height()
		x = e.pos().x() - w / 2.0
		y = e.pos().y() - h / 2.0
		x1, y1 = self.mapToImgdata(x, y)
		x2, y2 = self.mapToImgdata(x + w, y + h)
		self.amp[y1:y2,x1:x2] = 255.0
		self.update(x, 0, w, self.height())
		self.stored = False

	def mouseNormalEvent(self, e):
		if e.buttons() == Qt.LeftButton:
			diff = self.mapToParent(e.pos()) - self.__epos + self.__pos
			self.move(diff)
		elif e.buttons() == Qt.RightButton:
			diff = self.mapToParent(e.pos()) - self.__epos
			w = self.__size.width() + diff.x()
			h = self.__size.height() + diff.y()
			self.resize(w, h)

	def mouseReleaseEvent(self, e):
		self.cursorX.resize(1, self.height() * 4)
		self.cursorY.resize(self.width() * 4, 1)
		self.update(self.contentsRect())


|






>
>


|

|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|

|
|
|
|
|
|
|
|
|
|
|
|
|
|

|
|
|
|
|
|

|
|
|

|
|
|
|
|
|
|
|
<
>

|
|
|
|
|
|
|
|
|
|
|

|
|
|
|
|
|
|
|
|
|
|
|
|

|
|
|
|
|
|
|
|
|
|
|
|
|

|
|
|
|
|
|
|

|
|
|
|
|

|
|
|
|
|

|
|
|
|
|

|
|
|

|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|

|
|
|

|
|
|
|
|
|
|
|
|
|
|
|

|
|
|
|
|
|
|
|
|
|
|
|
|
|

|
|
|
|
|
|
|
|
|
|
|
|
|
|

|
|

|
|
|
|
|
|
|

|
|
|
|
|
|
|
|
|
|

|
|
|
|
|
|
|
|
|

|
|
|
|

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77

78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266

import logging, os, wave
from numpy import *
from scipy.signal import spectrogram
from PyQt5.QtCore import pyqtSignal, Qt, QPoint, QRect
from PyQt5.QtGui import QColor, QImage, QPainter, QPainterPath
from PyQt5.QtWidgets import QGraphicsOpacityEffect, QWidget

log = logging.getLogger()

class Spectrogram(QWidget):

   mouseOver = pyqtSignal(float, float)

   def __init__(self, fname, parent, config):
      super().__init__(parent)
      log.info(f'processing {fname}')
      self.conf = config
      self.setMouseTracking(True)
      self.setGeometry(parent.contentsRect())
      self.setAttribute(Qt.WA_DeleteOnClose)
      self.data = {
         'name': os.path.basename(fname),
         'path': fname,
         'pex': 0.0,
         'tex': 0.0,
         'fex': 0.0,
         'notes': ''}
      self.stored = False
      self.init_fft()
      if self.amp is None:
         parent.next_spectrogram()
         self.close()
         return
      self.init_calculations()
      self.init_image()
      self.fit()
      self.eraser = self.init_eraser(10, 200)
      self.eraser.maxheight = 200
      self.cursorX = self.init_cursor(1, self.height() * 4)
      self.cursorY = self.init_cursor(self.width() * 4, 1)
      self.show()

   def init_fft(self):
      fft = self.conf['fft']
      try:
         with wave.open(self.data['path'], 'rb') as song:
            channels, bs, samprate, nsamp, comp, compnam  = song.getparams()
            data = fromstring(song.readframes(nsamp), dtype=uint16)
      except wave.Error:
         log.warning(f'skipping \"{self.data["path"]}\": not a wave file.')
         return None
      f, t, amp = spectrogram(data, samprate, fft['window-function'],
            fft['window'], fft['overlap'], detrend=False, mode='psd' )
      self.freq = f
      self.time = t
      self.amp = amp

   def init_calculations(self):
      calc = self.conf['calculations']
      bot = argmax(self.freq > calc['high-pass'])
      top = argmin(self.freq < calc['low-pass'])
      self.freq = self.freq[bot:top]
      self.amp = self.amp[bot:top,:]

      self.thresh = self.amp.max() / 10**(calc['threshold'] / 10.0)
      self.thresh = 255.0 - self.thresh * 255.0 / self.amp.max()
      self.amp = 255.0 - self.amp * 255.0 / self.amp.max()

      arr = asarray(self.amp.flat)
      per = 100 * (arr <= self.thresh).sum() / len(arr)
      msg = f'{per:.2}%% of signal is above the threshold.'
      if per > 35.0:
         log.warning(f'{msg}  Consider using a lower threshold.')
      elif per < 5.0:
         log.warning(f'{msg}  Consider using a higher threshold.')
      else:

         log.info(f'{msg}')

   def init_image(self):
      colors = self.conf['colors']
      self.amp = flipud(self.amp)
      self.amp_back = copy(self.amp)
      self.freq = flipud(self.freq)
      self.imgdata = array(self.amp, uint8)
      h, w = self.imgdata.shape
      self.freq /= 1000.0
      self.image = QImage(self.imgdata.tobytes(), w, h, w, QImage.Format_Grayscale8)
      if colors['invert']:
         self.image.invertPixels()

   def init_cursor(self, w, h):
      cursor = QWidget(self)
      cursor.setAttribute(Qt.WA_TransparentForMouseEvents)
      effect = QGraphicsOpacityEffect(cursor)
      effect.setOpacity(0.4)
      cursor.setGraphicsEffect(effect)
      cursor.setGeometry(0, 0, w, h)
      cursor.setAutoFillBackground(True)
      p = cursor.palette()
      p.setColor(cursor.backgroundRole(), QColor(self.conf['colors']['cursor']))
      cursor.setPalette(p)
      cursor.hide()
      return cursor

   def init_eraser(self, w, h):
      eraser = QWidget(self)
      eraser.setAttribute(Qt.WA_TransparentForMouseEvents)
      effect = QGraphicsOpacityEffect(eraser)
      effect.setOpacity(0.4)
      eraser.setGraphicsEffect(effect)
      eraser.setGeometry(0, 0, w, h)
      eraser.setAutoFillBackground(True)
      p = eraser.palette()
      p.setColor(eraser.backgroundRole(), QColor(self.conf['colors']['eraser']))
      eraser.setPalette(p)
      eraser.hide()
      return eraser

   def fit(self):
      s = self.image.size()
      asp = s.width() / (s.height() * self.conf['scale-ratio'])
      if self.size().height() * asp > self.size().width():
         self.resize(self.width(), self.width() / asp)
      else:
         self.resize(self.height() * asp, self.height())

   def mode_normal(self):
      self.unsetCursor()
      self.eraser.hide()
      self.cursorX.hide()
      self.cursorY.hide()

   def mode_cursor(self):
      self.setCursor(Qt.BlankCursor)
      self.eraser.hide()
      self.cursorX.show()
      self.cursorY.show()

   def mode_eraser(self):
      self.setCursor(Qt.BlankCursor)
      self.cursorX.hide()
      self.cursorY.hide()
      self.eraser.show()

   def undo(self):
      copyto(self.amp, self.amp_back)
      self.update()

   def paintEvent(self, e):
      # TODO refactor this rats nest
      qp = QPainter(self)
      qp.setRenderHints(QPainter.SmoothPixmapTransform | QPainter.Antialiasing)
      qp.drawImage(self.contentsRect(), self.image, self.image.rect())
      r = QRect(0, 0, self.width() - 1, self.height() - 1)
      qp.setPen(QColor(self.conf['colors']['border']))
      qp.drawRect(r)
      path = QPainterPath()
      pfreq, ptime, pex, tex = 0.0, 0.0, 0.0, 0.0
      qp.setPen(QColor(self.conf['colors']['line']))
      for x, y in enumerate(argmin(self.amp, 0)):
         if self.amp[y,x] > self.thresh:
            continue
         t, f = self.time[x], self.freq[y]
         if self.conf['calculations']['log10-freq']:
            f = log10(f)
         if pfreq == 0.0:
            path.moveTo(x * self.sx, y * self.sy)
         else:
            path.lineTo(x * self.sx, y * self.sy)
            pex += sqrt((f - pfreq)**2 + (t - ptime)**2)
            tex += t - ptime
         ptime, pfreq = t, f
      qp.drawPath(path)
      self.data['pex'] = pex
      self.data['tex'] = tex
      try:
         self.data['fex'] = pex / tex
      except ZeroDivisionError:
         self.data['fex'] = 0.0
      #self.fexChanged.emit() # TODO replace this?

   def resizeEvent(self, e):
      self.sx = self.width() / self.image.width()
      self.sy = self.height() / self.image.height()

   def mousePressEvent(self, e):
      if self.cursorX.isVisible():
         pass
      elif self.eraser.isVisible():
         copyto(self.amp_back, self.amp)
         pass
      elif e.button() == Qt.MiddleButton:
         self.fit()
      else:
         self.__pos = self.pos()
         self.__size = self.size()
         self.__epos = self.mapToParent(e.pos())

   def mouseMoveEvent(self, e):
      # TODO refactor this rats nest
      # TODO add eraser size changes
      y = e.pos().y()
      if y < self.eraser.maxheight / 2.0:
         fixed = y * 2.0
      elif y > self.height() - self.eraser.maxheight / 2.0:
         fixed = (self.height() - y) * 2.0
      else:
         fixed = self.eraser.maxheight
      self.eraser.setFixedHeight(int(clip(fixed,0,self.eraser.maxheight)))
      x = e.pos().x() - self.eraser.width() / 2.0
      y = e.pos().y() - self.eraser.height() / 2.0
      self.eraser.move(x, y)

      x = e.pos().x()
      y = e.pos().y()
      self.cursorX.move(x, 0)
      self.cursorY.move(0, y)
      s = self.imgdata.shape
      x = clip(int(s[1] * e.pos().x() / self.width()), 0, self.time.size)
      y = clip(int(s[0] * e.pos().y() / self.height()), 0, self.freq.size)
      self.mouseOver.emit(self.time[x], self.time[y])
      if e.buttons() and self.cursorX.isVisible():
         self.mouseCursorEvent(e)
      elif e.buttons() and self.eraser.isVisible():
         self.mouseEraseEvent(e)
      elif e.buttons():
         self.mouseNormalEvent(e)

   def mouseCursorEvent(self, e):
      pass

   def mapToImgdata(self, x, y):
      s = self.imgdata.shape
      x *= s[1] / self.width()
      y *= s[0] / self.height()
      x = int(clip(x, 0, s[1]))
      y = int(clip(y, 0, s[0]))
      return x, y

   def mouseEraseEvent(self, e):
      w = self.eraser.width()
      h = self.eraser.height()
      x = e.pos().x() - w / 2.0
      y = e.pos().y() - h / 2.0
      x1, y1 = self.mapToImgdata(x, y)
      x2, y2 = self.mapToImgdata(x + w, y + h)
      self.amp[y1:y2,x1:x2] = 255.0
      self.update(x, 0, w, self.height())
      self.stored = False

   def mouseNormalEvent(self, e):
      if e.buttons() == Qt.LeftButton:
         diff = self.mapToParent(e.pos()) - self.__epos + self.__pos
         self.move(diff)
      elif e.buttons() == Qt.RightButton:
         diff = self.mapToParent(e.pos()) - self.__epos
         w = self.__size.width() + diff.x()
         h = self.__size.height() + diff.y()
         self.resize(w, h)

   def mouseReleaseEvent(self, e):
      self.cursorX.resize(1, self.height() * 4)
      self.cursorY.resize(self.width() * 4, 1)
      self.update(self.contentsRect())