├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── .gitignore ├── Makefile ├── generate_qthelp.sh ├── make.bat └── source │ ├── TimeView.png │ ├── conf.py │ ├── dataset.rst │ ├── index.rst │ ├── panel.rst │ ├── process.rst │ ├── track.rst │ └── view.rst ├── example ├── api_gui.py ├── api_processing.py ├── cough.edf ├── rodent-E1023.lab ├── rodent-E1023.tmv ├── rodent-E1023.wav ├── speech-mwm.lab └── speech-mwm.wav ├── icons └── TimeView.icns ├── setup.py ├── tests └── test.py ├── timeview.py └── timeview ├── __init__.py ├── __main__.py ├── api.py ├── dsp ├── __init__.py ├── dsp.py ├── processing.py ├── tracking.py ├── viterbi.py └── xtracking.py ├── gui ├── TimeView.icns ├── TimeView.qch ├── TimeView.qhc ├── __init__.py ├── dialogs.py ├── display_panel.py ├── extra.py ├── model.py ├── plot_area.py ├── plot_objects.py ├── rendering.py ├── view_table.py └── viewer.py └── manager ├── __init__.py ├── dataset_manager.py ├── dataset_manager_model.py └── main.ui /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled python files 2 | *.py[c|o] 3 | 4 | # Distribution/VirtualENV 5 | bin/ 6 | dist/ 7 | include/ 8 | lib/ 9 | 10 | # Cached python files 11 | *pycache* 12 | .pytest_cache/ 13 | .cache 14 | .coverage* 15 | *.egg-info 16 | 17 | # Folder preview 18 | .DS_Store 19 | .DS_Store/ 20 | Thumbs.db 21 | 22 | # IDE Related 23 | .idea 24 | .code 25 | 26 | # App files 27 | timeview/gui/config.json 28 | timeview/manager/dataset.db 29 | 30 | # git .orig files 31 | *.orig 32 | .eggs 33 | .mypy_cache 34 | 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2005-2017 TimeView Developers 4 | Alexander Kain 5 | Ognyan Moore 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include timeview/manager/main.ui 2 | include timeview/manager/dataset.db 3 | recursive-include timeview/ *.json 4 | recursive-include timeview/ *.qhc 5 | recursive-include timeview/ *.qch 6 | recursive-include timeview/ *.icns -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | TimeView 2 | = 3 | 4 | ![screenshot](docs/source/TimeView.png) 5 | 6 | Overview 7 | - 8 | Timeview is a cross-platform desktop application for viewing and editing 9 | Waveforms, Time-Value data, and Segmentation data. 10 | These data can easily be analyzed or manipulated using a library of built-in processors; 11 | for example, a linear filter can operate on a waveform, or an activity detector can create a segmentation from a waveform. 12 | Processors can be easily customized or created created from scratch. 13 | 14 | This is a very early preview, and is not suitable for general usage yet. 15 | 16 | 17 | Features 18 | - 19 | * *Cross-platform*, verified to run on macOS, Linux, and Windows 20 | * Flexible arrangement of any number of *panels*, 21 | which contain any number of superimposed *views* (e.g. waveforms, spectrograms, feature trajectories, segmentations) 22 | * Views can easily be *moved* between panels 23 | * Views can be *linked* so that modifications in one panel are reflected in other panels 24 | * *Customizable Rendering* of views (e.g. frame_size for spectrogram) 25 | * *On-the-fly Spectrogram* rendering automatically adjusts frame-rate and FFT-size to calculate information for each available pixel without interpolation 26 | * *Editable segmentation* (insertion, deletion, modification of boundaries; modification of labels) 27 | * Basic *processing plug-ins* are provided (e.g. activity detection, F0-analysis) 28 | * Processing plug-ins are easily *customizable* or *extendable* using python (bridging to R via `rpy2` is also possible, an example is provided) 29 | * API allows accessing processing plugins for *batch file processing* or *preconfiguring the GUI* (examples are provided) 30 | * *EDF-file-format* support 31 | * A *dataset-manager* allows grouping of files into datasets, for quick access to often-used files 32 | * *Command Line Interface* support, for easy chaining with other tools 33 | 34 | An introductory video is available at: https://vimeo.com/245480108 35 | 36 | 37 | Installation 38 | - 39 | From an empty python 3.6+ python environment run 40 | 41 | ``` 42 | $ pip install git+https://github.com/lxkain/timeview 43 | $ timeview 44 | $ timeview -h 45 | ``` 46 | 47 | Development Environment 48 | - 49 | In your 3.6+ python environment run 50 | 51 | ``` 52 | $ git clone https://github.com/lxkain/timeview.git 53 | $ cd timeview 54 | $ python timeview.py 55 | ``` 56 | 57 | Help 58 | - 59 | After the application has started, select "Help" from the Menu, and then "TimeView Help" to learn more. 60 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = TimeView 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/generate_qthelp.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | #DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | #ROOT=${DIR}/.. 5 | #source ${ROOT}/miniconda/envs/timeview/bin/activate timeview 6 | 7 | # http://www.sphinx-doc.org/en/stable/builders.html 8 | make qthelp 9 | 10 | # http://doc.qt.io/qt-5/qthelp-framework.html 11 | qcollectiongenerator build/qthelp/TimeView.qhcp 12 | 13 | mv build/qthelp/TimeView.qhc build/qthelp/TimeView.qch ../timeview/gui 14 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | set SPHINXPROJ=TimeView 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/source/TimeView.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimeViewers/timeview/27c1ba3d468ee3a11a60006fbd4b7f9ada0e61e3/docs/source/TimeView.png -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # TimeView documentation build configuration file, created by 5 | # sphinx-quickstart on Wed Sep 20 15:45:33 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # The suffix(es) of source filenames. 40 | # You can specify multiple suffix as a list of string: 41 | # 42 | # source_suffix = ['.rst', '.md'] 43 | source_suffix = '.rst' 44 | 45 | # The master toctree document. 46 | master_doc = 'index' 47 | 48 | # General information about the project. 49 | project = 'TimeView' 50 | copyright = '2017, TimeView Developers' 51 | author = 'TimeView Developers' 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The short X.Y version. 58 | version = '1.0' 59 | # The full version, including alpha/beta/rc tags. 60 | release = '1.0' 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | # 65 | # This is also used if you do content translation via gettext catalogs. 66 | # Usually you set "language" from the command line for these cases. 67 | language = None 68 | 69 | # List of patterns, relative to source directory, that match files and 70 | # directories to ignore when looking for source files. 71 | # This patterns also effect to html_static_path and html_extra_path 72 | exclude_patterns = [] 73 | 74 | # The name of the Pygments (syntax highlighting) style to use. 75 | pygments_style = 'sphinx' 76 | 77 | # If true, `todo` and `todoList` produce output, else they produce nothing. 78 | todo_include_todos = False 79 | 80 | 81 | # -- Options for HTML output ---------------------------------------------- 82 | 83 | # The theme to use for HTML and HTML Help pages. See the documentation for 84 | # a list of builtin themes. 85 | # 86 | html_theme = 'alabaster' 87 | 88 | # Theme options are theme-specific and customize the look and feel of a theme 89 | # further. For a list of options available for each theme, see the 90 | # documentation. 91 | # 92 | # html_theme_options = {} 93 | 94 | # Add any paths that contain custom static files (such as style sheets) here, 95 | # relative to this directory. They are copied after the builtin static files, 96 | # so a file named "default.css" will overwrite the builtin "default.css". 97 | html_static_path = ['_static'] 98 | 99 | # Custom sidebar templates, must be a dictionary that maps document names 100 | # to template names. 101 | # 102 | # This is required for the alabaster theme 103 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 104 | html_sidebars = { 105 | '**': [ 106 | 'about.html', 107 | 'navigation.html', 108 | 'relations.html', # needs 'show_related': True theme option to display 109 | 'searchbox.html', 110 | 'donate.html', 111 | ] 112 | } 113 | 114 | 115 | # -- Options for HTMLHelp output ------------------------------------------ 116 | 117 | # Output file base name for HTML help builder. 118 | htmlhelp_basename = 'TimeViewdoc' 119 | 120 | 121 | # -- Options for LaTeX output --------------------------------------------- 122 | 123 | latex_elements = { 124 | # The paper size ('letterpaper' or 'a4paper'). 125 | # 126 | # 'papersize': 'letterpaper', 127 | 128 | # The font size ('10pt', '11pt' or '12pt'). 129 | # 130 | # 'pointsize': '10pt', 131 | 132 | # Additional stuff for the LaTeX preamble. 133 | # 134 | # 'preamble': '', 135 | 136 | # Latex figure (float) alignment 137 | # 138 | # 'figure_align': 'htbp', 139 | } 140 | 141 | # Grouping the document tree into LaTeX files. List of tuples 142 | # (source start file, target name, title, 143 | # author, documentclass [howto, manual, or own class]). 144 | latex_documents = [ 145 | (master_doc, 'TimeView.tex', 'TimeView Documentation', 146 | 'TimeView Developers', 'manual'), 147 | ] 148 | 149 | 150 | # -- Options for manual page output --------------------------------------- 151 | 152 | # One entry per manual page. List of tuples 153 | # (source start file, name, description, authors, manual section). 154 | man_pages = [ 155 | (master_doc, 'timeview', 'TimeView Documentation', 156 | [author], 1) 157 | ] 158 | 159 | 160 | # -- Options for Texinfo output ------------------------------------------- 161 | 162 | # Grouping the document tree into Texinfo files. List of tuples 163 | # (source start file, target name, title, author, 164 | # dir menu entry, description, category) 165 | texinfo_documents = [ 166 | (master_doc, 'TimeView', 'TimeView Documentation', 167 | author, 'TimeView', 'One line description of project.', 168 | 'Miscellaneous'), 169 | ] 170 | 171 | 172 | 173 | -------------------------------------------------------------------------------- /docs/source/dataset.rst: -------------------------------------------------------------------------------- 1 | Dataset Manager 2 | =============== 3 | 4 | The dataset manager window is available by selecting Window/Dataset Manager from the menu. 5 | Datasets are a logical grouping of files. 6 | The same file can belong to different datasets. 7 | 8 | The top pane lists the available datasets. 9 | The bottom pane lists the files associated with the selected dataset. 10 | You can resize the column widths by dragging the edge of the column header cell. 11 | 12 | To add a dataset, click the dataset "+" button and enter the (unique) name of the new dataset. 13 | To remove a dataset, click the dataset "-" button; note that no actual files will be deleted. 14 | 15 | To see which files belong to a dataset, select the desired dataset row, and the bottom pane will list the associated files. 16 | 17 | To add one or more files, click the file "+" button; use modifier keys to add multiple files at once in the file chooser. 18 | To delete one or more files, click one or more files (you can also use modifier keys), and then click the file "-" button. 19 | 20 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. TimeView documentation master file, created by 2 | sphinx-quickstart on Wed Sep 20 15:45:33 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | TimeView Documentation 7 | ====================== 8 | 9 | Welcome to TimeView, a cross-platform (Windows, MacOS, Linux) desktop application for viewing and editing 10 | time-based signals such as waveform, time-value, and segmentation data. 11 | It is possible to "undock" this help window from the main application window, by clicking on the "two windows" icon on the top right. 12 | Now you can move and resize the help window. 13 | 14 | After starting the application, you are presented with a single empty panel. 15 | 16 | First Steps using Speech Data 17 | ----------------------------- 18 | 19 | Open the supplied speech waveform by selecting Track/Open from the menu. 20 | Select "speech-mwm.wav" in the supplied "example" directory. 21 | The waveform should display using the default time-domain based Waveform rendering. 22 | You can now navigate time using either 23 | (1) using the mouse, by left-click dragging in the view area to *move*, or by right-click dragging or using the wheel to *zoom*, 24 | (2) the Navigation menu, or 25 | (3) the associated arrow-based keyboard shortcuts. 26 | 27 | Change the Rendering to "Spectrogram", and navigate around. 28 | You can change the color of the spectrogram by clicking on the "color" table cell. 29 | You can change the frame *size* (and other parameters) by choosing Track/Options from the menu; 30 | this sets configuration parameters for the current view. 31 | The frame *rate* is automatically updated dependent on the number of available pixels, to always give you the highest time-resolution possible. 32 | You can also increase or decrease the panel vertical height from the Panel menu or the associated shortcuts. 33 | In the case of rendering the spectrogram, this may increase the FFT size to always give you the highest frequency-resolution possible. 34 | 35 | Sometimes it is useful to look at the same data at *different* time locations. 36 | To do this, uncheck the Panel/Synchronize checkbox in the menu. 37 | Link the current view by right-clicking on the file name and then selecting "Link Track / Link to New Panel". 38 | Notice that the time axes of the two panels are now independent. 39 | Check the synchronize checkbox again, and notice that the currently selected panel sets the time region of the other panels. 40 | Now close the second panel by selecting "Panel / Remove Panel" from the menu, or using the associated shortcut. 41 | 42 | Focus in on a spectrogram frequence range of interest by calling up the options and setting "y_max" to "8000". 43 | 44 | Let's find regions of activity by running the "Activity Detector" processor from the Processing Menu. 45 | A dialogue box will appear. Accept the defaults and click "Process". 46 | Two more tracks will be added, the activity segmentation (a Partition-type track) with "0" and "1" labels, 47 | and the maximum energy per frame (a Time-Value-type track), 48 | the treshold on which the activation detection is based. 49 | To see the units of energy in dB, make sure the TimeValue track is currently selected, 50 | as the y-axis is always labeled with the currently selected view. 51 | 52 | You may want to see the spectrogram and the waveform at the same time. 53 | To do this, right-click on the speech.wav filename and select "Link to New Panel" and set the rendering to "Waveform". 54 | Now link the "speech-mwm-act.lab" file to the second panel as well. 55 | Finally, hide the energy view by unchecking the "Show" checkbox. 56 | This leaves just the spectrogram and the segmentation visible. 57 | 58 | Let's say that you are not completely satisfied with the segmentation, and would like to adjust its boundaries. 59 | Change one (or both) of the partition rendering to "editable", and drag the boundaries around. 60 | Notice how the two segmentations remain synchronized. 61 | To remove a boundary, double-click it. 62 | To add a new boundary, double-click between existing boundaries, and you can start typing the new label for this segment. 63 | You can click any existing segment label to edit it. 64 | TAB and shift-TAB cycles through the segment labels forward and backward. 65 | 66 | You can move views from panel to panel anytime by right-clicking on the filename and selecting "Move View". 67 | 68 | 69 | First Steps using Rodent Vocalization Data 70 | ------------------------------------------ 71 | 72 | Open the supplied rodent audio waveform by selecting Track/Open from the menu. 73 | Select "rodent-E1023.wav" in the supplied "example" directory. 74 | The waveform should display using the default time-domain based rendering. 75 | Change the Rendering to "Spectrogram" and change 76 | 77 | , and navigate around, by using either 78 | (1) using the mouse, by left-click dragging in the view area to *move*, or by right-click dragging or using the wheel to *zoom*, 79 | (2) the Navigation menu, or 80 | (3) the associated arrow-based keyboard shortcuts. 81 | 82 | Zoom in on the region around 40 seconds. 83 | Change the frame_size of the spectrogram by clicking on the sliders icon to configure the parameters. 84 | Set "frame_size"=0.001. 85 | 86 | . Let's remove additive noise by running the "Noise Reducer" Processor from the Processing Menu. 87 | . A dialogue box will appear. Accept the defaults and click "Process" - this may take a while. 88 | . After the output appears, change the output rendering to "Spectrogram". 89 | . Remove the original by selecting it and clicking on the "delete" pushbutton. 90 | 91 | Let's find regions of activity by running the "Activity Detector" processor from the Processing Menu. 92 | A dialogue box will appear. 93 | Change the defaults to reflect "threshold" = -24.5, "smooth" = 0.5, "frame_size"=0.001, "frame_rate"=0.001, and click "Process". 94 | Two more tracks will be added, the activity segmentation (a Partition-type track), and the maximum energy per frame (a Time-Value-type track), on which the activation detection is based. 95 | Hide the energy track, and change the rendering for the segmentation to "editable". 96 | Now you can change boundaries and labels as in the speech example. 97 | 98 | Finally, let's run the "Peak Tracker (active regions only)" Processor. 99 | Specify "freq_min" = 40000, "freq_max" = 120000, "frame_size" = 0.001, "frame_rate" = 0.001, "smooth"=0.1,and "NFFT" = 512. 100 | After a while, you will see a Time-Value object appear that tracks the spectral peaks. 101 | 102 | Table of Contents 103 | ----------------- 104 | 105 | (also always available on the left) 106 | 107 | .. toctree:: 108 | :maxdepth: 2 109 | 110 | track.rst 111 | panel.rst 112 | view.rst 113 | process.rst 114 | dataset.rst 115 | 116 | 117 | 118 | .. 119 | Indices and tables 120 | ================== 121 | * :ref:`genindex` 122 | * :ref:`modindex` 123 | * :ref:`search` 124 | -------------------------------------------------------------------------------- /docs/source/panel.rst: -------------------------------------------------------------------------------- 1 | Panels 2 | ====== 3 | 4 | A :index:`panel` shows a view area on the left, and a table of currently active views on the right. 5 | The view table can be hidden by moving the splitter all the way to the right. 6 | The view table can be shown again by moving the splitter left. 7 | 8 | A user can *add* or *remove* a panel, and *increase* or *decrease* its vertical height, by selecting the appropriate menu entries. 9 | 10 | Panels can be either time-synchronized, or all have individually selectable time regions. To turn synchronization on or off, select the "Synchronize" menu option. 11 | When turning it on, all panels will match the time region of the last selected panel. 12 | 13 | A user can also *re-order* a panel by dragging it by the whitespace just below the view table, moving it either above or below other panels. 14 | 15 | -------------------------------------------------------------------------------- /docs/source/process.rst: -------------------------------------------------------------------------------- 1 | Processors 2 | ========== 3 | 4 | "Plug-in" :index:`processors` are external pieces of python code that are registered with the application. 5 | As input they require one or more track objects, and possibly additional user-configurable parameters. 6 | These inputs are set via a dialog-box. 7 | Processing outputs will appear as one or more track objects using default rendering. 8 | 9 | A user can initiate a process from the menu. 10 | 11 | A new plug-in is very easy to write in either the python or the R language. 12 | This allows for extensive customization, convenient development of new algorithms, and powerful leveraging of existing python libraries. 13 | -------------------------------------------------------------------------------- /docs/source/track.rst: -------------------------------------------------------------------------------- 1 | Tracks 2 | ====== 3 | 4 | TimeView supports three different kind of :index:`track` objects: 5 | 6 | 1. Wave (e.g. an audio waveform, sampled at a *uniform* interval) 7 | 2. Time-Value data (e.g. a contour, sampled at *non-uniform* intervals) 8 | 3. Partition (also known as Segmentation, e.g. a phonetic segmentation) 9 | 10 | At the moment TimeView supports loading of Waveforms in .wav format, Partitions in .lab format, and EDF files in .edf format. 11 | 12 | A user can load a track from disk, and see additional information about the current track by selecting the appropriate entries from the menu. 13 | 14 | A user can also create a new Partition track. 15 | 16 | A view is a particular way of displaying a track's information, according to a *rendering* strategy. One or more views can be shown in a panel. 17 | -------------------------------------------------------------------------------- /docs/source/view.rst: -------------------------------------------------------------------------------- 1 | Views 2 | ====== 3 | 4 | A :index:`view` is comprised of a track, a specific method to display the track's information in the form of a *renderer*, and other information (e.g. color). 5 | The view area on the left of the panel shows one or more overlapping rendering outputs, while the view table on the right side of the panel shows the currently active views. 6 | 7 | Each view may offer several different types of rendering. A view can be also be shown or hidden, and its colors and y-axis range can be changed. 8 | All these actions can be accomplished by clicking on the associated table cells. 9 | 10 | To add a new view and thus also a new track, click the "+" button. This is identical to choosing File/Open. 11 | To remove a view, select the view to be removed, and click the "-" button. 12 | 13 | 14 | Navigation 15 | ---------- 16 | Moving around in time is easy. A user can move forward/right, backward/left, and zoom in and out. 17 | It is also possible to go to the very beginning or end of the track. 18 | Finally, the zoom level can be set to "1:1", which means that the resolution of the screen is equal to the resolution of the underlying signals, 19 | and the zoom level can be set to "Fit", which means that the zoom level will be set such that all tracks are visible. 20 | 21 | It is also possible to drag the mouse cursor left and right to move forward or backward in time, or to use the mouse wheel to change zoom levels. 22 | These actions are also available via the menu, and finally also via convenient keyboard shortcuts using the arrow keys. 23 | 24 | 25 | Rendering 26 | --------- 27 | 28 | The following Renderers are available: 29 | 30 | * Waveform: Time-Domain representation 31 | This renderer features supersampling for fast display of even very large waveforms. 32 | 33 | * Waveform: Frequency-Domain representation / Spectrogram 34 | The frame rate of the spectrogram is set automatically such that additional detail becomes available when zooming in. 35 | 36 | * Time-Value: Time-Domain representation 37 | 38 | * Partition: read-only 39 | Read-only rendering is ideal for viewing-only 40 | 41 | * Partition: editable 42 | This renderer allows editing of label boundaries and values. 43 | To *move* boundaries, simply drag the vertical line after it has turned red. 44 | To *remove* a boundary, double-click the vertical line after it has turned red. 45 | To *add* a new boundary, double-click in-between existing lines, but be sure none of them have turned red. If necessary, zoom in more. 46 | To *edit* a label value, double-click on the text-box. The TAB key, and shift-TAB allow for quick back and forth selection of values. 47 | 48 | It is relatively easy to write additional renderers for custom visualizations. 49 | -------------------------------------------------------------------------------- /example/api_gui.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Example of using the TimeView python API to configure the GUI and start it 5 | """ 6 | 7 | import sys 8 | from pathlib import Path 9 | 10 | import numpy as np 11 | 12 | sys.path.insert(1, str(Path(__file__).resolve().parents[1])) 13 | from timeview.api import Track, Wave, TimeView 14 | 15 | 16 | # read from disk 17 | wav = Track.read(Path(__file__).with_name('speech-mwm.wav')) 18 | lab = Track.read(Path(__file__).with_name('speech-mwm.lab')) 19 | 20 | # create ourselves 21 | fs = 16000 22 | x = np.zeros(2 * fs, dtype=np.float64) 23 | x[1 * fs] = 1 24 | syn = Wave(x, fs) 25 | 26 | app = TimeView() 27 | app.add_view(wav, 0, y_min=-10_000, y_max=10_000) 28 | app.add_view(lab, 0) 29 | app.add_view(wav, 1, renderer_name='Spectrogram') # linked 30 | app.add_view(lab, 1) # linked 31 | app.add_view(syn, 2) 32 | app.add_view(syn, 2, renderer_name='Spectrogram', y_max=4000) # linked 33 | 34 | app.start() 35 | -------------------------------------------------------------------------------- /example/api_processing.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Example of using the TimeView python API to process files without the GUI 5 | 6 | Activate the conda timeview environment before running this 7 | """ 8 | 9 | # TODO: this should be moved to signalworks, and plugins should be defined there 10 | 11 | import sys 12 | from pathlib import Path 13 | 14 | sys.path.append(str(Path(__file__).resolve().parents[1])) 15 | from timeview.api import Track, processing 16 | 17 | 18 | wav_name = Path(__file__).with_name('speech-mwm.wav') 19 | wav = Track.read(wav_name) 20 | par_name = Path(__file__).with_name('speech-mwm.lab') 21 | par = Track.read(par_name) 22 | 23 | processor = processing.Filter() 24 | processor.set_data({'wave': wav}) 25 | results, = processor.process() 26 | 27 | print(results) 28 | -------------------------------------------------------------------------------- /example/cough.edf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimeViewers/timeview/27c1ba3d468ee3a11a60006fbd4b7f9ada0e61e3/example/cough.edf -------------------------------------------------------------------------------- /example/rodent-E1023.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimeViewers/timeview/27c1ba3d468ee3a11a60006fbd4b7f9ada0e61e3/example/rodent-E1023.wav -------------------------------------------------------------------------------- /example/speech-mwm.lab: -------------------------------------------------------------------------------- 1 | 0.0000000 0.3330000 .pau_L 2 | 0.3330000 0.6660000 .pau_R 3 | 0.6660000 0.6815000 tc_L 4 | 0.6815000 0.6970000 tc_R 5 | 0.6970000 0.7250000 th_L 6 | 0.7250000 0.7530000 th_R 7 | 0.7530000 0.7875000 u_L 8 | 0.7875000 0.8220000 u_R 9 | 0.8220000 0.8830000 f_L 10 | 0.8830000 0.9440000 f_R 11 | 0.9440000 1.0095000 3r_L 12 | 1.0095000 1.0750000 3r_R 13 | 1.0750000 1.0960000 D_L 14 | 1.0960000 1.1170000 D_R 15 | 1.1170000 1.1810000 3r_L 16 | 1.1810000 1.2450000 3r_R 17 | 1.2450000 1.2695000 h_L 18 | 1.2695000 1.2940000 h_R 19 | 1.2940000 1.3370000 I_L 20 | 1.3370000 1.3800000 I_R 21 | 1.3800000 1.4220000 s_L 22 | 1.4220000 1.4640000 s_R 23 | 1.4640000 1.4895000 pc_L 24 | 1.4895000 1.5150000 pc_R 25 | 1.5150000 1.5250000 ph_L 26 | 1.5250000 1.5350000 ph_R 27 | 1.5350000 1.5490000 9r_L 28 | 1.5490000 1.5630000 9r_R 29 | 1.5630000 1.6060000 E_L 30 | 1.6060000 1.6490000 E_R 31 | 1.6490000 1.6990000 s_L 32 | 1.6990000 1.7490000 s_R 33 | 1.7490000 1.7650000 tc_L 34 | 1.7650000 1.7810000 tc_R 35 | 1.7810000 1.7920000 th_L 36 | 1.7920000 1.8030000 th_R 37 | 1.8030000 1.9145000 i_L 38 | 1.9145000 2.0260000 i_R 39 | 2.0260000 2.0845000 Z_L 40 | 2.0845000 2.1430000 Z_R 41 | 2.1430000 2.1505000 h_L 42 | 2.1505000 2.1580000 h_R 43 | 2.1580000 2.1943887 i_L 44 | 2.1943887 2.2307775 i_R 45 | 2.2307775 2.2638888 &_L 46 | 2.2638888 2.2970000 &_R 47 | 2.2970000 2.3275000 kc_L 48 | 2.3275000 2.3580000 kc_R 49 | 2.3580000 2.3825000 kh_L 50 | 2.3825000 2.4070000 kh_R 51 | 2.4070000 2.4830000 ei_L 52 | 2.4830000 2.5590000 ei_R 53 | 2.5590000 2.5965000 Z_L 54 | 2.5965000 2.6340000 Z_R 55 | 2.6340000 2.6600000 n_L 56 | 2.6600000 2.6860000 n_R 57 | 2.6860000 2.7015000 &_L 58 | 2.7015000 2.7170000 &_R 59 | 2.7170000 2.7570000 l_L 60 | 2.7570000 2.7970000 l_R 61 | 2.7970000 2.8340000 I_L 62 | 2.8340000 2.8710000 I_R 63 | 2.8710000 2.9175000 9r_L 64 | 2.9175000 2.9640000 9r_R 65 | 2.9640000 3.0230000 i_L 66 | 3.0230000 3.0820000 i_R 67 | 3.0820000 3.1030000 dc_L 68 | 3.1030000 3.1240000 dc_R 69 | 3.1240000 3.1780000 s_L 70 | 3.1780000 3.2320000 s_R 71 | 3.2320000 3.2390000 D_L 72 | 3.2390000 3.2460000 D_R 73 | 3.2460000 3.2695000 &_L 74 | 3.2695000 3.2930000 &_R 75 | 3.2930000 3.3220000 w_L 76 | 3.3220000 3.3510000 w_R 77 | 3.3510000 3.4020000 >_L 78 | 3.4020000 3.4530000 >_R 79 | 3.4530000 3.4730000 l_L 80 | 3.4730000 3.4930000 l_R 81 | 3.4930000 3.5360000 s_L 82 | 3.5360000 3.5790000 s_R 83 | 3.5790000 3.5890000 tc_L 84 | 3.5890000 3.5990000 tc_R 85 | 3.5990000 3.6205000 th_L 86 | 3.6205000 3.6420000 th_R 87 | 3.6420000 3.6635000 9r_L 88 | 3.6635000 3.6850000 9r_R 89 | 3.6850000 3.7205000 i_L 90 | 3.7205000 3.7560000 i_R 91 | 3.7560000 3.7915000 dZc_L 92 | 3.7915000 3.8270000 dZc_R 93 | 3.8270000 3.8595000 dZ_L 94 | 3.8595000 3.8920000 dZ_R 95 | 3.8920000 3.9600000 3r_L 96 | 3.9600000 4.0280000 3r_R 97 | 4.0280000 4.0505000 n_L 98 | 4.0505000 4.0730000 n_R 99 | 4.0730000 4.0925000 &_L 100 | 4.0925000 4.1120000 &_R 101 | 4.1120000 4.1500000 l_L 102 | 4.1500000 4.1880000 l_R 103 | 4.1880000 4.4155000 .pau_L 104 | 4.4155000 4.6430000 .pau_R 105 | -------------------------------------------------------------------------------- /example/speech-mwm.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimeViewers/timeview/27c1ba3d468ee3a11a60006fbd4b7f9ada0e61e3/example/speech-mwm.wav -------------------------------------------------------------------------------- /icons/TimeView.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimeViewers/timeview/27c1ba3d468ee3a11a60006fbd4b7f9ada0e61e3/icons/TimeView.icns -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | requirements = ["numpy", #""intel-numpy", 4 | "scipy", 5 | "sqlalchemy", 6 | "numba", 7 | "qtpy", 8 | "pyqtgraph", 9 | "qtawesome", 10 | "pyedflib"] 11 | 12 | test_requirements = ["pytest", 13 | "pytest-qt", 14 | "pytest-runner"] 15 | 16 | 17 | setup( 18 | # meta-data 19 | name='TimeView', 20 | version='0.1.0', 21 | description="A GUI application to view and analyze time series signal data", 22 | author=["Alexander Kain", "Ognyan Moore"], 23 | author_email=['lxkain@gmail.com', 'ognyan.moore@gmail.com'], 24 | url='https://github.com/lxlain/timeview', 25 | keywords='timeview gui pyqt signal spectrogram', 26 | classifiers=[ 27 | 'Development Status :: 3 - Alpha', 28 | 'Programming Language :: Python :: 3.6', 29 | 'Topic :: Scientific/Engineering', 30 | 'Topic :: Multimedia :: Sound/Audio :: Analysis', 31 | 'Topic :: Utilities', 32 | 'Intended Audience :: Science/Research', 33 | 'License :: OSI Approved :: MIT License' 34 | ], 35 | license='MIT', 36 | # app contents 37 | packages=['timeview', 38 | 'timeview.dsp', 39 | 'timeview.gui', 40 | 'timeview.manager'], 41 | include_package_data=True, 42 | # launching 43 | entry_points={ 44 | 'gui_scripts': [ 45 | 'timeview = timeview.__main__' 46 | ] 47 | }, 48 | # dependencies 49 | install_requires=requirements, 50 | tests_require=test_requirements, 51 | extras_require={ 52 | 'dev': [ 53 | "numpydoc", 54 | "flake8", 55 | "mypy", 56 | "pylint" 57 | ], 58 | 'test': test_requirements 59 | }, 60 | python_requires=">=3.6.0", 61 | # setup_requires=["pytest-runner"], 62 | ) 63 | -------------------------------------------------------------------------------- /tests/test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from pathlib import Path 3 | 4 | from ..timeview.dsp import tracking, processing 5 | 6 | # see also: http://johnnado.com/pyqt-qtest-example/ 7 | 8 | class TestProcessors(unittest.TestCase): 9 | def setUp(self): 10 | wav_name = Path(__file__).parents[2] / 'dat/speech.wav' 11 | self.wav = tracking.Track.read(wav_name) 12 | par_name = Path(__file__).parents[2] / 'dat/speech.lab' 13 | self.par = tracking.Track.read(par_name) 14 | 15 | def test_PeakTracker(self): 16 | processor = processing.PeakTracker() 17 | processor.set_data({'wave': self.wav}) 18 | processor.process() 19 | 20 | def test_RodentCallClassifier(self): 21 | processor = processing.PeakTracker() 22 | processor.set_data({'wave': self.wav}) 23 | peak, = processor.process() 24 | processor = processing.RodentCallClassifier() 25 | processor.set_data({'activity partition': self.par, 'peak track': peak}) 26 | processor.process() 27 | 28 | def test_F0Analyzer(self): 29 | processor = processing.F0Analyzer() 30 | # step 1: set data (because parameter defaults may depend on data properties) 31 | processor.set_data({'wave': self.wav}) 32 | processor.process() 33 | 34 | 35 | -------------------------------------------------------------------------------- /timeview.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from timeview.__main__ import main 4 | 5 | main() 6 | -------------------------------------------------------------------------------- /timeview/__init__.py: -------------------------------------------------------------------------------- 1 | # This is empty, because: 2 | # https://stackoverflow.com/questions/43393764/python-3-6-project-structure-leads-to-runtimewarning 3 | # 4 | # Historically: 5 | # https://www.reddit.com/r/Python/comments/1bbbwk/whats_your_opinion_on_what_to_include_in_init_py/ 6 | # 7 | # see also api.py -------------------------------------------------------------------------------- /timeview/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | TimeView CLI and GUI Application 3 | """ 4 | 5 | from pathlib import Path 6 | import argparse 7 | 8 | from .gui import TimeView 9 | 10 | 11 | def parse(args): 12 | # ENHANCE: think about how to specify CLI loading configurations in a .cfg file 13 | if args.configuration == 'default': # one object per panel 14 | app = TimeView() 15 | for i, path in enumerate(args.path): 16 | print(f'Loading {path}') 17 | app.add_view_from_file(Path(path), panel_index=i) 18 | app.start() 19 | elif args.configuration == 'labeling': 20 | raise NotImplementedError 21 | else: 22 | raise Exception('unhandled configuration') 23 | 24 | 25 | def main(): 26 | configurations = ['default'] # , 'labeling'] 27 | parser = argparse.ArgumentParser(description=__doc__, 28 | epilog="© Copyright 2009-2019, TimeView Developers", prog='TimeView') 29 | parser.add_argument('-c', '--configuration', type=str, default='default', choices=configurations) 30 | parser.add_argument('path', type=str, nargs='*', help='files to load') 31 | parser.set_defaults(func=parse) 32 | args = parser.parse_args() 33 | args.func(args) 34 | 35 | 36 | main() 37 | -------------------------------------------------------------------------------- /timeview/api.py: -------------------------------------------------------------------------------- 1 | # public API 2 | from .gui.viewer import TimeView 3 | 4 | from .dsp.tracking import Track, Wave, TimeValue, Partition, TIME_TYPE 5 | from .dsp import processing 6 | -------------------------------------------------------------------------------- /timeview/dsp/__init__.py: -------------------------------------------------------------------------------- 1 | from . import dsp, processing, tracking, viterbi 2 | -------------------------------------------------------------------------------- /timeview/dsp/dsp.py: -------------------------------------------------------------------------------- 1 | """ 2 | Digital Signal Processing 3 | """ 4 | from math import ceil, log2 5 | from typing import Union, Tuple 6 | 7 | import numpy as np 8 | from numpy.fft import fft, ifft, rfft, irfft 9 | from scipy import signal, stats 10 | import numba 11 | 12 | from . import tracking 13 | 14 | # TODO: make this "tracking"-free (?), and all times are in samples 15 | 16 | # def segment_talkbox(a: np.ndarray, length: int, overlap: int = 0) -> np.ndarray: 17 | # # originally from talkbox.segmentaxis 18 | # a = np.ravel(a) # may copy 19 | # l = a.shape[0] 20 | # if overlap >= length: 21 | # raise ValueError("frames cannot overlap by more than 100%") 22 | # if overlap < 0 or length <= 0: 23 | # raise ValueError("overlap must be nonnegative and length must be positive") 24 | # if l < length or (l - length) % (length - overlap): 25 | # if l > length: 26 | # roundup = length + (1 + (l - length) // 27 | # (length - overlap)) * (length - overlap) 28 | # # TODO: further optimization possible 29 | # rounddown = length + ((l - length) // (length - overlap)) * (length - overlap) 30 | # else: 31 | # roundup = length 32 | # rounddown = 0 33 | # assert rounddown < l < roundup 34 | # assert roundup == rounddown + (length - overlap) or (roundup == length and rounddown == 0) 35 | # a = a.swapaxes(-1, 0) 36 | # a = a[..., :rounddown] 37 | # a = a.swapaxes(-1, 0) 38 | # l = a.shape[0] 39 | # if l == 0: 40 | # raise ValueError("Not enough data points to segment array") 41 | # assert l >= length 42 | # assert (l - length) % (length - overlap) == 0 43 | # n = 1 + (l - length) // (length - overlap) 44 | # s = a.strides[0] 45 | # newshape = a.shape[:0] + (n, length) + a.shape[1:] 46 | # newstrides = a.strides[:0] + ((length - overlap) * s, s) + a.strides[1:] 47 | # try: 48 | # return np.ndarray.__new__(np.ndarray, strides=newstrides, 49 | # shape=newshape, buffer=a, dtype=a.dtype) 50 | # except TypeError: 51 | # import warnings 52 | # warnings.warn("Problem with ndarray creation forces copy.") 53 | # a = a.copy() 54 | # # Shape doesn't change but strides does 55 | # newstrides = a.strides[:0] + ((length - overlap) * s, s) + a.strides[1:] 56 | # return np.ndarray.__new__(np.ndarray, 57 | # strides=newstrides, 58 | # shape=newshape, 59 | # buffer=a, 60 | # dtype=a.dtype) 61 | 62 | 63 | # @numba.jit((numba.int16[:], numba.int64, numba.int32), nopython=True, cache=True) 64 | @numba.jit(nopython=True, cache=True) # we need polymorphism here 65 | def segment(x, nsize, nrate): 66 | if len(x) < nsize: 67 | F = 0 68 | else: 69 | F = (len(x) - nsize) // nrate # the number of full frames 70 | assert F >= 0 71 | X = np.empty((F, nsize), dtype=x.dtype) 72 | a = 0 73 | for f in range(F): 74 | X[f, :] = x[a:a + nsize] 75 | a += nrate 76 | return X 77 | 78 | 79 | def frame(wav: tracking.Wave, 80 | frame_size: float, 81 | frame_rate: float) -> tracking.TimeValue: 82 | """ 83 | Given a waveform, return a timeValue track with each frame as the value and times of the center of each frame. 84 | times point to the center of the frame. 85 | Each frame will have the specified size, and t[i+1] = t[i] + rate. 86 | this will return as much of the signal as possible in full frames 87 | """ 88 | # def unsigned int a, f, nrate, nsize 89 | assert wav.duration > 0 90 | nsize = int(round(frame_size * wav.fs)) 91 | nrate = int(round(frame_rate * wav.fs)) 92 | # import time 93 | # tic = time.time() 94 | # print("frame timing...") 95 | # if 0: # TODO: unfortunately segment doesn't allow for negative overlap, i.e. jumps 96 | # value = segment_talkbox(wav.value, nsize, nsize - nrate) # because overlap = nsize - nrate 97 | value = segment(wav.value, nsize, nrate) 98 | # print(f"frame took time: {time.time() - tic}") 99 | assert value.shape[1] == nsize 100 | time = np.array(np.arange(value.shape[0]) * nrate, dtype=tracking.TIME_TYPE) + nsize // 2 101 | return tracking.TimeValue(time, value, wav.fs, wav.duration, path=wav.path) # adjust path name here? 102 | 103 | 104 | #@numba.jit(nopython=True, cache=True) # we need polymorphism here 105 | def frame_centered(signal: np.ndarray, time: np.ndarray, frame_size: int) -> np.ndarray: 106 | assert time.ndim == 1 107 | # no further assumptions on time - doesn't have to be sorted or inside signal 108 | value = np.zeros((len(time), frame_size), dtype=signal.dtype) 109 | left_frame_size = frame_size // 2 110 | right_frame_size = frame_size - left_frame_size 111 | S = len(signal) 112 | for f, center in enumerate(time): 113 | left = center - left_frame_size 114 | right = center + right_frame_size 115 | if left >= 0 and right <= S: # make the common case fast 116 | value[f, :] = signal[left:right] 117 | else: # deal with edges on possibly both sides 118 | # left 119 | if left < 0: 120 | left_avail = left_frame_size + left 121 | else: 122 | left_avail = left_frame_size 123 | # right 124 | right_over = right - S 125 | if right_over > 0: 126 | right_avail = right_frame_size - right_over 127 | else: 128 | right_avail = right_frame_size 129 | if 0 <= center <= S: 130 | value[f, left_frame_size - left_avail:left_frame_size + right_avail] = signal[center - left_avail: center + right_avail] 131 | assert value.shape[0] == len(time) 132 | assert value.shape[1] == frame_size 133 | return value # adjust path name here? 134 | 135 | 136 | 137 | 138 | @numba.jit(nopython=True, cache=True) # we need polymorphism here 139 | def ola(frame, fs, duration, frame_size: float, frame_rate: float): 140 | nsize = int(round(frame_size * fs)) 141 | nrate = int(round(frame_rate * fs)) 142 | y = np.zeros(duration, dtype=np.float64) 143 | a = 0 144 | for f in range(len(frame)): 145 | y[a:a + nsize] += frame[f] 146 | a += nrate 147 | return y 148 | 149 | 150 | def spectral_subtract(inp, frame_rate, silence_percentage: int): 151 | assert 0 < silence_percentage < 100 152 | ftr = frame(inp, frame_rate * 2, frame_rate) 153 | x = ftr.value * signal.hann(ftr.value.shape[1]) 154 | X = fft(x, 2 ** nextpow2(x.shape[1])) 155 | M = np.abs(X) 156 | E = np.mean(M ** 2, axis=1) 157 | threshold = stats.scoreatpercentile(E, silence_percentage) 158 | index = np.where(E < threshold)[0] 159 | noise_profile = np.median(M[index], axis=0) 160 | M -= noise_profile 161 | np.clip(M, 0, None, out=M) # limit this to a value greater than 0 to avoid -inf due to the following log 162 | Y = M * np.exp(1j * np.angle(X)) # DEBUG 163 | y = ifft(Y).real 164 | s = ola(y, inp.fs, inp.duration, frame_rate * 2, frame_rate) 165 | return tracking.Wave(s, inp.fs) 166 | 167 | 168 | def spectrogram(wav: tracking.Wave, 169 | frame_size: float, 170 | frame_rate: float, 171 | window=signal.hann, 172 | NFFT='nextpow2', 173 | normalized=False) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: 174 | """return log-magnitude spectrogram in dB""" 175 | ftr = frame(wav, frame_size, frame_rate) 176 | x = ftr.value * window(ftr.value.shape[1]) 177 | if NFFT == 'nextpow2': 178 | NFFT = 2 ** nextpow2(x.shape[1]) 179 | M = np.abs(rfft(x, NFFT)) 180 | np.clip(M, 1e-12, None, out=M) 181 | M = np.log10(M) * 20 182 | if normalized: 183 | M = (M.T - np.min(M, axis=1)).T 184 | M = (M.T / np.max(M, axis=1)).T 185 | assert np.all(M.min(axis=1) == 0) 186 | assert np.all(M.max(axis=1) == 1) 187 | frequency = np.arange(M.shape[1]) / M.shape[1] * wav.fs / 2 188 | return M, ftr.time, frequency 189 | 190 | 191 | def spectrogram_centered(wav: tracking.Wave, # used by rendering 192 | frame_size: float, 193 | time: np.ndarray, 194 | window=signal.hann, 195 | NFFT='nextpow2', 196 | normalized=False) -> Tuple[np.ndarray, np.ndarray]: 197 | """return log-magnitude spectrogram in dB""" 198 | s = wav.value / np.abs(np.max(wav.value)) # make float by normalizing and later clipping is more uniform 199 | assert s.max() == 1 200 | ftr = frame_centered(s, time, int(round(frame_size * wav.fs))) 201 | assert ftr.dtype == np.float 202 | ftr *= window(ftr.shape[1]) 203 | if NFFT == 'nextpow2': 204 | NFFT = 2 ** nextpow2(ftr.shape[1]) 205 | M = np.abs(rfft(ftr, NFFT)) 206 | np.clip(M, 1e-16, None, out=M) 207 | M[:] = np.log10(M) * 20 208 | if normalized: 209 | M[:] = (M.T - np.min(M, axis=1)).T 210 | M[:] = (M.T / np.max(M, axis=1)).T 211 | #assert np.all(M.min(axis=1) == 0) 212 | #assert np.all(M.max(axis=1) == 1) 213 | frequency = np.arange(M.shape[1]) / M.shape[1] * wav.fs / 2 214 | return M, frequency 215 | 216 | 217 | def correlate_fft(X: np.ndarray): 218 | """correlation for feature matrix""" 219 | assert X.ndim == 2 220 | D = X.shape[1] 221 | R = irfft(np.abs(rfft(X, 2 ** nextpow2(2 * D - 1))) ** 2)[:, :D] 222 | # show relationship to related methods 223 | assert np.allclose(R[0], np.correlate(X[0], X[0], mode='full')[D - 1:]) 224 | # assert np.allclose(r, np.convolve(x, x[::-1], mode='full')[n - 1:]) 225 | from scipy.signal import fftconvolve 226 | # assert np.allclose(r, fftconvolve(x, x[::-1], mode='full')[n - 1:]) 227 | return R 228 | 229 | 230 | def correlogram(wav: tracking.Wave, frame_size: float, frame_rate: float, normalize: bool = True): 231 | assert wav.dtype == np.float64 232 | # t, x = frame(wav, frame_size, frame_rate) 233 | ftr = frame(wav, frame_size, frame_rate) 234 | M, D = ftr.value.shape 235 | R = correlate_fft(ftr.value) 236 | # if 1: 237 | # # FFT order must be at least 2*len(x)-1 and should be a power of 2 238 | # R = irfft(np.abs(rfft(ftr.value, 2 ** nextpow2(2 * D - 1))) ** 2)[:, D] 239 | # else: 240 | # index = np.arange(int(np.round(D / 2)), D) 241 | # R = np.empty((M, len(index)), dtype=np.float64) 242 | # for m in range(M): 243 | # signal = ftr.value[m] 244 | # R[m, :] = np.correlate(signal, signal, mode='same')[index] # TODO: use fft2 here instead 245 | if normalize: 246 | R[:, 1:] /= np.tile(R[:, 0], (R.shape[1] - 1, 1)).T # keep energy in zero-th coeff? 247 | frequency = np.r_[np.nan, wav.fs / np.arange(1, R.shape[1])] 248 | return R, ftr.time, frequency 249 | 250 | 251 | def nextpow2(i: Union[int, float]) -> int: 252 | """returns the first P such that 2**P >= abs(N)""" 253 | return int(ceil(log2(i))) 254 | 255 | 256 | -------------------------------------------------------------------------------- /timeview/dsp/processing.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABCMeta, abstractmethod 3 | from pathlib import Path 4 | from typing import List, Tuple, Dict, Optional, Union, Callable 5 | 6 | import numpy as np 7 | from scipy import signal 8 | 9 | 10 | from . import tracking 11 | from . import dsp 12 | from . import viterbi 13 | 14 | Tracks = Union[tracking.Wave, tracking.TimeValue, tracking.Partition] 15 | # type alias 16 | 17 | 18 | class InvalidDataError(Exception): 19 | pass 20 | 21 | 22 | class InvalidParameterError(Exception): 23 | pass 24 | 25 | 26 | class ProcessError(Exception): 27 | pass 28 | 29 | 30 | class DefaultProgressTracker(object): 31 | def update(self, value: int): 32 | print(f"{value}%", end='...', flush=True) 33 | 34 | 35 | class Processor(metaclass=ABCMeta): 36 | name = "Processor" 37 | acquire: Dict[str, object] = {} # TODO: how to merge with data? 38 | 39 | def __init__(self): 40 | self.data: Dict[str, Tracks] = {} 41 | self.parameters: Dict[str, Tracks] = {} # default parameters 42 | self.progressTracker = None 43 | 44 | def set_data(self, data: Dict[str, Tracks]) -> None: 45 | for key in self.acquire.keys(): 46 | if type(data[key]) != self.acquire[key]: # check type 47 | raise InvalidDataError 48 | self.data = data 49 | 50 | def get_parameters(self) -> Dict[str, str]: 51 | # default parameters can be modified here based on the data 52 | return {k: str(v) for k, v in self.parameters.items()} 53 | 54 | def set_parameters(self, parameters: Dict[str, str]) -> None: 55 | if __debug__: 56 | for name, value in parameters.items(): 57 | logging.debug(f'Received parameter {name} of value {value}') 58 | try: 59 | for key in parameters.keys(): 60 | if type(self.parameters[key]) == np.ndarray: 61 | self.parameters[key] = np.fromstring(parameters[key] 62 | .rstrip(')]') 63 | .lstrip('[('), 64 | sep=' ') 65 | else: 66 | self.parameters[key] =\ 67 | type(self.parameters[key])(parameters[key]) 68 | except Exception as e: 69 | raise InvalidParameterError(e) 70 | # additional parameter checking can be performed here 71 | 72 | def process(self, progressTracker=None): # -> Tuple[Tracks]: 73 | """ 74 | :param progressTracker: call process_tracker.updateProgress(int) 75 | with an integer between 0-100 indicating how far process is 76 | :return: 77 | """ 78 | if progressTracker is None: 79 | self.progressTracker = DefaultProgressTracker() 80 | else: 81 | self.progressTracker = progressTracker 82 | 83 | def del_data(self): 84 | self.data = {} 85 | 86 | 87 | def get_processor_classes() -> Dict[str, Callable[..., Processor]]: 88 | def all_subclasses(c): 89 | return c.__subclasses__() + [a for b in c.__subclasses__() 90 | for a in all_subclasses(b)] 91 | 92 | return {obj.name: obj for obj in all_subclasses(Processor)} 93 | 94 | 95 | ####################################################################################################################### 96 | 97 | # 98 | # class ConverterToFloat64(Processor): 99 | # name = 'Conversion to Float64' 100 | # acquire = {'wave': tracking.Wave} 101 | # 102 | # def process(self, **kwargs) -> Tuple[tracking.Wave]: 103 | # Processor.process(self, **kwargs) 104 | # wav = self.data['wave'] 105 | # wav = wav.convert_dtype(np.float64) 106 | # wav.path = wav.path.with_name(wav.path.stem + '-float64').with_suffix( 107 | # tracking.Wave.default_suffix) 108 | # return wav, 109 | # 110 | # 111 | # class ConverterToInt16(Processor): 112 | # name = 'Conversion to Int16' 113 | # acquire = {'wave': tracking.Wave} 114 | # 115 | # def process(self, **kwargs) -> Tuple[tracking.Wave]: 116 | # Processor.process(self, **kwargs) 117 | # wav = self.data['wave'] 118 | # wav = wav.convert_dtype(np.int16) 119 | # wav.path = wav.path.with_name(wav.path.stem + '-int16').with_suffix( 120 | # tracking.Wave.default_suffix) 121 | # return wav, 122 | # 123 | 124 | class Filter(Processor): 125 | name = 'Linear Filter' 126 | acquire = {'wave': tracking.Wave} 127 | 128 | def __init__(self): 129 | super().__init__() 130 | # default is pre-emphasis 131 | self.parameters = {'B': np.array([1., -.95]), 132 | 'A': np.array([1.])} 133 | 134 | def process(self, **kwargs) -> Tuple[tracking.Wave]: 135 | Processor.process(self, **kwargs) 136 | wav = self.data['wave'] 137 | x = wav.value 138 | self.progressTracker.update(10) 139 | y = signal.lfilter(self.parameters['B'], 140 | self.parameters['A'], x).astype(x.dtype) 141 | self.progressTracker.update(90) 142 | new_track = tracking.Wave(y, 143 | fs=wav.fs, 144 | path=wav.path 145 | .with_name(wav.path.stem + '-filtered') 146 | .with_suffix(tracking.Wave 147 | .default_suffix)), 148 | return new_track 149 | 150 | 151 | class ZeroPhaseFilter(Filter): 152 | name = 'Zero-phase Linear Filter' 153 | acquire = {'wave': tracking.Wave} 154 | 155 | def process(self, **kwargs) -> Tuple[tracking.Wave]: 156 | Processor.process(self, **kwargs) 157 | wav = self.data['wave'] 158 | x = wav.value 159 | self.progressTracker.update(10) 160 | y = signal.filtfilt(self.parameters['B'], 161 | self.parameters['A'], x).astype(x.dtype) 162 | self.progressTracker.update(90) 163 | return tracking.Wave(y, 164 | fs=wav.fs, 165 | path=wav.path 166 | .with_name(wav.path.stem + 167 | '-0phasefiltered') 168 | .with_suffix(wav.path.suffix)), 169 | 170 | 171 | class EnergyEstimator(Processor): 172 | name = 'RMS-Energy (dB)' 173 | acquire = {'wave': tracking.Wave} 174 | 175 | def __init__(self): 176 | super().__init__() 177 | self.parameters = {'frame_size': 0.020, # in seconds 178 | 'frame_rate': 0.010} # in seconds 179 | 180 | def process(self, **kwargs) -> Tuple[tracking.TimeValue]: 181 | Processor.process(self, **kwargs) 182 | wav = self.data['wave'] 183 | wav = wav.convert_dtype(np.float64) 184 | self.progressTracker.update(10) 185 | frame = dsp.frame(wav, 186 | self.parameters['frame_size'], 187 | self.parameters['frame_rate']) 188 | self.progressTracker.update(70) 189 | frame.value *= signal.hann(frame.value.shape[1]) 190 | value = 20 * np.log10(np.mean(frame.value ** 2.0, axis=1) ** 0.5) 191 | self.progressTracker.update(90) 192 | nrg = tracking.TimeValue(frame.time, 193 | value, 194 | wav.fs, 195 | wav.duration, 196 | path=wav.path 197 | .with_name(wav.path.stem + '-energy') 198 | .with_suffix(tracking.TimeValue 199 | .default_suffix)) 200 | nrg.min = value.min() 201 | nrg.max = value.max() 202 | nrg.unit = 'dB' 203 | return nrg, 204 | 205 | 206 | class SpectralDiscontinuityEstimator(Processor): 207 | name = 'Spectral Discontinuity Estimator' 208 | acquire = {'wave': tracking.Wave} 209 | 210 | def __init__(self): 211 | super().__init__() 212 | self.parameters = {'frame_size': 0.005, # seconds, determines freq res. 213 | 'NFFT': 256, 214 | 'normalized': 1, 215 | 'delta_order':1} 216 | 217 | def process(self, **kwargs) -> Tuple[tracking.TimeValue]: 218 | Processor.process(self, **kwargs) 219 | # wav = self.data['wave'] 220 | wav: tracking.Wave = self.data['wave'] 221 | self.progressTracker.update(10) 222 | ftr, time, frequency = dsp.spectrogram(wav, 223 | self.parameters['frame_size'], 224 | self.parameters['frame_size'], # frame_rate = frame_size 225 | NFFT=self.parameters['NFFT'], 226 | normalized=self.parameters['normalized']) 227 | if self.parameters['normalized']: 228 | ftr = ftr - np.mean(ftr, axis=1).reshape(-1, 1) 229 | 230 | time = (time[:-1] + time[1:]) // 2 231 | assert self.parameters['delta_order'] > 0 232 | dynamic_win = np.arange(-self.parameters['delta_order'], self.parameters['delta_order'] + 1) 233 | 234 | win_width = self.parameters['delta_order'] 235 | win_length = 2 * win_width + 1 236 | den = 0 237 | for s in range(1, win_width+1): 238 | den += s**2 239 | den *= 2 240 | dynamic_win = dynamic_win / den 241 | 242 | N, D = ftr.shape 243 | print(N) 244 | temp_array = np.zeros((N + 2 * win_width, D)) 245 | delta_array = np.zeros((N, D)) 246 | self.progressTracker.update(90) 247 | temp_array[win_width:N+win_width] = ftr 248 | for w in range(win_width): 249 | temp_array[w, :] = ftr[0, :] 250 | temp_array[N+win_width+w,:] = ftr[-1,:] 251 | 252 | for i in range(N): 253 | for w in range(win_length): 254 | delta_array[i, :] += temp_array[i+w,:] * dynamic_win[w] 255 | value = np.mean(np.diff(delta_array, axis=0) ** 2, axis=1) ** 0.5 256 | dis = tracking.TimeValue(time, value, wav.fs, wav.duration, path=wav.path.with_name(wav.path.stem + '-discont') 257 | .with_suffix(tracking.TimeValue 258 | .default_suffix)) 259 | dis.min = 0 260 | dis.max = value.max() 261 | dis.unit = 'dB' 262 | dis.label = 'spectral discontinuity' 263 | self.progressTracker.update(100) 264 | return dis, 265 | 266 | 267 | 268 | class NoiseReducer(Processor): 269 | name = 'Noise Reducer' 270 | acquire = {'wave': tracking.Wave} 271 | 272 | def __init__(self): 273 | super().__init__() 274 | self.parameters = {'silence_percentage': 10, 275 | 'frame_rate': 0.01} # in seconds 276 | 277 | def process(self, **kwargs) -> Tuple[tracking.Wave]: 278 | Processor.process(self, **kwargs) 279 | inp = self.data['wave'] 280 | inp = inp.convert_dtype(np.float64) 281 | self.progressTracker.update(20) 282 | out = dsp.spectral_subtract(inp, self.parameters['frame_rate'], self.parameters['silence_percentage']) # TODO: pull this up into here 283 | self.progressTracker.update(90) 284 | out.path = inp.path.with_name(inp.path.stem + '-denoised').with_suffix( 285 | tracking.Wave.default_suffix) 286 | return out, 287 | 288 | 289 | class ActivityDetector(Processor): 290 | name = 'Activity Detector' 291 | acquire = {'wave': tracking.Wave} 292 | 293 | def __init__(self): 294 | super().__init__() 295 | self.parameters = {'threshold': -30.0, 296 | 'smooth': 1.0, 297 | 'frame_size': 0.020, # in seconds 298 | 'frame_rate': 0.01} 299 | 300 | def process(self, **kwargs) -> Tuple[tracking.Partition, tracking.TimeValue]: 301 | Processor.process(self, **kwargs) 302 | wav = self.data['wave'] 303 | wav = wav.convert_dtype(np.float64) 304 | self.progressTracker.update(10) 305 | M, time, frequency = dsp.spectrogram(wav, 306 | self.parameters['frame_size'], 307 | self.parameters['frame_rate']) 308 | self.progressTracker.update(20) 309 | # Emax = np.atleast_2d(np.max(M, axis=1)).T 310 | Emax = 20 * np.log10(np.mean((10 ** (M / 10)), axis=1) ** 0.5) 311 | P = np.empty((len(Emax), 2)) 312 | P[:, 0] = 1 / (1 + np.exp(Emax - self.parameters['threshold'])) 313 | P[:, 1] = 1 - P[:, 0] # complement 314 | self.progressTracker.update(30) 315 | seq, _ = viterbi.search_smooth(P, self.parameters['smooth']) 316 | self.progressTracker.update(90) 317 | tmv = tracking.TimeValue(time, seq, wav.fs, wav.duration, 318 | wav.path.with_name(wav.path.stem + '-act') 319 | .with_suffix( 320 | tracking.TimeValue.default_suffix)) 321 | par = tracking.Partition.from_TimeValue(tmv) 322 | par.value = np.char.mod('%d', par.value) 323 | emax = tracking.TimeValue(time, Emax, wav.fs, wav.duration, 324 | wav.path.with_name(wav.path.stem + '-emax') 325 | .with_suffix( 326 | tracking.TimeValue.default_suffix)) 327 | emax.min = Emax.min() 328 | emax.max = Emax.max() 329 | emax.unit = 'dB' 330 | emax.label = 'maximum frequency magnitude' 331 | return par, emax 332 | 333 | 334 | class F0Analyzer(Processor): 335 | name = 'F0 Analysis' 336 | acquire = {'wave': tracking.Wave} 337 | 338 | def __init__(self): 339 | super().__init__() 340 | self.t0_min = 0 341 | self.t0_max = 0 342 | self.parameters = {'smooth': 0.01, 343 | 'f0_min': 51, # in Hertz 344 | 'f0_max': 300, # in Hertz 345 | 'frame_size': 0.040, # in seconds 346 | 'frame_rate': 0.010, # in seconds 347 | 'dop threshold': 0.7, 348 | 'energy threshold': 0.1} 349 | 350 | def set_parameters(self, parameter: Dict[str, str]): 351 | super().set_parameters(parameter) 352 | assert self.parameters['f0_min'] < self.parameters['f0_max'],\ 353 | 'f0_min must be < f0_max' 354 | assert self.parameters['frame_size'] >\ 355 | (2 / self.parameters['f0_min']), 'frame_size must be > 2 / f0_min' 356 | 357 | def process(self, **kwargs) -> Tuple[tracking.TimeValue, 358 | tracking.TimeValue, 359 | tracking.Partition]: 360 | Processor.process(self, **kwargs) 361 | wav = self.data['wave'] 362 | wav = wav.convert_dtype(np.float64) 363 | self.progressTracker.update(10) 364 | R, time, frequency = dsp.correlogram(wav, 365 | self.parameters['frame_size'], 366 | self.parameters['frame_rate']) 367 | 368 | self.progressTracker.update(30) 369 | t0_min = int(round(wav.fs / self.parameters['f0_max'])) 370 | t0_max = int(round(wav.fs / self.parameters['f0_min'])) 371 | index = np.arange(t0_min, t0_max + 1, dtype=np.int) 372 | E = R[:, 0] # energy 373 | R = R[:, index] # only look at valid candidates 374 | # normalize 375 | R -= R.min() 376 | R /= R.max() 377 | # find best sequence 378 | seq, _ = viterbi.search_smooth(R, self.parameters['smooth']) 379 | self.progressTracker.update(80) 380 | # if 0: 381 | # from matplotlib import pyplot as plt 382 | # plt.imshow(R.T, aspect='auto', origin='lower', cmap=plt.cm.pink) 383 | # plt.plot(seq) 384 | # plt.show() 385 | # F0 track 386 | f0 = wav.fs / (t0_min + seq) 387 | # degree of periodicity 388 | dop = R[np.arange(R.shape[0]), seq] 389 | # voicing 390 | v = ((dop > self.parameters['dop threshold']) & 391 | (E > self.parameters['energy threshold']) 392 | # (seq > 0) & (seq < len(index) - 1) 393 | ).astype(np.int) 394 | v = signal.medfilt(v, 5) # TODO: replace by a 2-state HMM 395 | f0[v == 0] = np.nan 396 | # prepare tracks 397 | f0 = tracking.TimeValue(time, f0, wav.fs, wav.duration, 398 | wav.path 399 | .with_name(wav.path.stem + '-f0') 400 | .with_suffix(tracking.TimeValue 401 | .default_suffix)) 402 | f0.min = self.parameters['f0_min'] 403 | f0.max = self.parameters['f0_max'] 404 | f0.unit = 'Hz' 405 | f0.label = 'F0' 406 | dop = tracking.TimeValue(time, dop, wav.fs, wav.duration, 407 | wav.path 408 | .with_name(wav.path.stem + '-dop') 409 | .with_suffix( 410 | tracking.TimeValue.default_suffix)) 411 | dop.min = 0 412 | dop.max = 1 413 | dop.label = 'degree of periodicity' 414 | vox = tracking.TimeValue(time, v, wav.fs, wav.duration, 415 | wav.path 416 | .with_name(wav.path.stem + '-vox') 417 | .with_suffix( 418 | tracking.TimeValue.default_suffix)) 419 | vox = tracking.Partition.from_TimeValue(vox) 420 | vox.label = 'voicing' 421 | return f0, dop, vox 422 | 423 | 424 | class Differentiator(Processor): 425 | name = "Differentiator" 426 | acquire = {'wave': tracking.Wave} 427 | 428 | def __init__(self): 429 | super().__init__() 430 | self.parameters = {} 431 | 432 | def process(self, **kwargs) -> Tuple[tracking.Wave]: 433 | Processor.process(self, **kwargs) 434 | wav: tracking.Wave = self.data['wave'] 435 | trk = tracking.Wave(wav.value.copy(), wav.fs, wav.duration, path=wav.path.with_name(wav.path.stem + '-diff')) 436 | trk.value = np.gradient(trk.value) 437 | trk.max = wav.max 438 | trk.min = wav.min 439 | trk.unit = wav.unit 440 | trk.label = wav.label 441 | return trk, 442 | 443 | 444 | class PeakTracker(Processor): 445 | name = 'Peak Tracker' 446 | acquire = {'wave': tracking.Wave} 447 | 448 | def __init__(self): 449 | super().__init__() 450 | self.parameters = {'smooth': 1., 451 | 'freq_min': 100, 452 | 'freq_max': 1000, 453 | 'frame_size': 0.02, # seconds, determines freq res. 454 | 'frame_rate': 0.01, 455 | 'NFFT': 512} 456 | 457 | def get_parameters(self): 458 | if 'wave' in self.data: 459 | self.parameters['freq_max'] = self.data['wave'].fs / 2 460 | return super().get_parameters() 461 | 462 | def set_parameters(self, parameter: Dict[str, str]): 463 | super().set_parameters(parameter) 464 | if not self.parameters['freq_min'] < self.parameters['freq_max']: 465 | raise InvalidParameterError('freq_min must be < freq_max') 466 | 467 | def process(self, **kwargs) -> Tuple[tracking.TimeValue]: 468 | Processor.process(self, **kwargs) 469 | # wav = self.data['wave'] 470 | wav: tracking.Wave = self.data['wave'] 471 | self.progressTracker.update(10) 472 | ftr, time, frequency = dsp.spectrogram(wav, 473 | self.parameters['frame_size'], 474 | self.parameters['frame_rate'], 475 | NFFT=self.parameters['NFFT']) 476 | self.progressTracker.update(50) 477 | a = frequency.searchsorted(self.parameters['freq_min']) 478 | b = frequency.searchsorted(self.parameters['freq_max']) 479 | # import time as timer 480 | # print('searching') 481 | # tic = timer.time() 482 | seq, _ = viterbi.search_smooth(ftr[:, a:b], self.parameters['smooth']) 483 | self.progressTracker.update(90) 484 | # toc = timer.time() 485 | # print(f'done, took: {toc-tic}') 486 | trk = tracking.TimeValue(time, frequency[a + seq], wav.fs, wav.duration, 487 | wav.path 488 | .with_name(wav.path.stem + '-peak') 489 | .with_suffix( 490 | tracking.TimeValue.default_suffix)) 491 | trk.min = 0 492 | trk.max = wav.fs / 2 493 | trk.unit = 'Hz' 494 | trk.label = 'frequency' 495 | return trk, 496 | 497 | 498 | class PeakTrackerActiveOnly(PeakTracker): 499 | name = 'Peak Tracker (active regions only)' 500 | acquire = {'wave': tracking.Wave, 'active': tracking.Partition} 501 | 502 | def process(self, **kwargs) -> Tuple[tracking.TimeValue]: 503 | peak = super().process(**kwargs)[0] 504 | active = self.data['active'] 505 | for i in range(len(active.time) - 1): 506 | if active.value[i] in ['0', 0]: 507 | a = np.searchsorted(peak.time / peak.fs, active.time[i] / active.fs) 508 | b = np.searchsorted(peak.time / peak.fs, active.time[i+1] / active.fs) 509 | peak.value[a:b] = np.nan 510 | return peak, 511 | 512 | 513 | # from rpy2 import robjects 514 | # from rpy2.robjects.packages import SignatureTranslatedAnonymousPackage 515 | # class ExampleR(Processor): 516 | # name = 'Example R plug-in' 517 | # acquire = {'activity partition': tracking.Partition, 518 | # 'peak track': tracking.TimeValue} 519 | # 520 | # def __init__(self): 521 | # super().__init__() 522 | # self.parameters = {'some': 0} 523 | # 524 | # def process(self, **kwargs) -> Tuple[tracking.Partition]: 525 | # Processor.process(self, **kwargs) 526 | # par: tracking.Partition = self.data['activity partition'] 527 | # tmv: tracking.TimeValue = self.data['peak track'] 528 | # fs = max(par.fs, tmv.fs) 529 | # par = par.resample(fs) 530 | # tmv = tmv.resample(fs) 531 | # self.progressTracker.update(10) 532 | # with open(Path(__file__).resolve().parent / 'example.R') as f: 533 | # script = f.read() 534 | # self.progressTracker.update(11) 535 | # pack = SignatureTranslatedAnonymousPackage(script, 'pack') 536 | # self.progressTracker.update(12) 537 | # pack.setup() 538 | # self.progressTracker.update(13) 539 | # value =\ 540 | # pack.predict_rodent_class(robjects.FloatVector(par.time / par.fs), 541 | # robjects.FloatVector(par.value), 542 | # robjects.FloatVector(tmv.time / tmv.fs), 543 | # robjects.FloatVector(tmv.value), 544 | # par.fs) 545 | # self.progressTracker.update(90) 546 | # par = tracking.Partition(par.time, np.array(value), fs=fs, 547 | # path=par.path 548 | # .with_name(par.path.stem + '-class') 549 | # .with_suffix( 550 | # tracking.Partition.default_suffix)) 551 | # return par, -------------------------------------------------------------------------------- /timeview/dsp/viterbi.py: -------------------------------------------------------------------------------- 1 | """Viterbi""" 2 | 3 | import numpy as np 4 | import numba 5 | 6 | 7 | @numba.jit((numba.float64[:, :], numba.float64), nopython=True, cache=True) # eager compilation through function signature 8 | def search_smooth(ftr, smooth): # 100% function-based 9 | """P is the number of candidates at each time 10 | T is the number of available time points""" 11 | T, P = ftr.shape 12 | assert T > 1 13 | assert P < 65536 # could adjust dtype automatically, currently set at uint16 14 | trans = np.empty(P) 15 | zeta = np.empty(P) 16 | score = np.empty((2, P)) # one row for previous, and one for current score (no tracking for all t, saving memory) 17 | path = np.zeros((T, P), np.uint16) - 1 # often this matrix has less than %1 of meaningful entries, could be made sparse 18 | seq = np.zeros(T, np.uint16) - 1 # the - 1 (really: 65535) helps catch bugs 19 | # forward 20 | for t in range(T): 21 | # print(t, T) 22 | current = t % 2 # 2-cell ring buffer pointer 23 | previous = (t - 1) % 2 24 | # OBSERVATION t 25 | # observe(t, ftr, score[score_cur]) # score is the output 26 | score[current, :] = ftr[t] 27 | # OBSERVATION t 28 | if t == 0: 29 | jindex = np.where(score[0])[0] # active FROM nodes 30 | assert len(jindex), 'no observations for target[0]' 31 | else: 32 | iindex = np.where(score[current] > -np.inf)[0] # possible TO nodes 33 | # assert len(iindex), 'no observation probabilities above pruning threshold for target[%d]' % t 34 | for i in iindex: # TO this node - TODO: this may be parallelizable 35 | # TRANSITION jindex -> i @ t 36 | # transition(t, ftr, jindex, i, trans) # trans is the output 37 | trans[jindex] = -np.abs(jindex - i) * smooth 38 | # TRANSITION jindex -> i @ t 39 | # zeta[jindex] = score[score_prv, jindex] + trans[jindex] 40 | # zeta[jindex] = score[score_prv][jindex] + trans[jindex] 41 | zeta = score[previous] + trans # really only needed over jindex, but indexing is slow 42 | path[t, i] = zindex = jindex[zeta[jindex].argmax()] 43 | score[current, i] += zeta[zindex] 44 | assert np.any(score[current] > -np.inf), 'score/prob[t] must not be all below pruning threshold' 45 | jindex = iindex # new active FROM nodes 46 | # backward 47 | assert current == (T - 1) % 2 48 | # seq[-1] = iindex[score[score_cur, iindex].argmax()] 49 | seq[-1] = iindex[score[current][iindex].argmax()] 50 | for t in range(T - 1, 0, -1): 51 | seq[t - 1] = path[t, seq[t]] 52 | return seq, score[current, seq[-1]] -------------------------------------------------------------------------------- /timeview/dsp/xtracking.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Union 2 | 3 | import numpy as np 4 | import xarray as xr 5 | 6 | 7 | class Track(object): # Deriving from DataArray is not well-supported for version 0.9.6, see also issue #1097 8 | def __init__(self, 9 | data: np.ndarray, 10 | fs: int, 11 | coords: Dict[str, np.ndarray]=None, 12 | dims=None, 13 | name: str=None, 14 | attrs: dict=None): 15 | if attrs is None: 16 | attrs = {'fs': fs} 17 | else: 18 | attrs['fs'] = fs 19 | self.xar = xr.DataArray(data, coords=coords, dims=dims, name=name, attrs=attrs) 20 | # store everything in attrs in the hope that someday we can derive 21 | 22 | def get_coords(self): 23 | return self.xar.coords 24 | coords = property(get_coords) 25 | 26 | def get_time(self) -> np.array: 27 | return self.xar.coords['time'].values # this exists even if coords were not specified 28 | time = property(get_time) 29 | 30 | def get_realtime(self): 31 | return self.get_time() / self.xar.attrs['fs'] 32 | 33 | realtime = property(get_realtime) 34 | 35 | def __str__(self): 36 | return self.xar.__str__() 37 | 38 | def __repr__(self): 39 | return self.xar.__repr__() 40 | 41 | 42 | class Signal(Track): 43 | def __init__(self, 44 | data: np.ndarray, 45 | fs: int, 46 | coords=None, 47 | dims=None, 48 | name: str=None, 49 | attrs: dict=None): 50 | assert data.ndim == 2 51 | if coords is None: 52 | if dims is None: 53 | dims = ('time', 'amplitude') # for default Wave 54 | else: 55 | assert dims[0] == 'time' 56 | else: 57 | if isinstance(coords, dict): 58 | assert 'time' in coords 59 | else: 60 | assert coords[0][0] == 'time' 61 | # if coords are not specified, coordinate variables are auto-generated if accessed & attrs are not possible 62 | Track.__init__(self, data, fs, coords=coords, dims=dims, name=name, attrs=attrs) 63 | 64 | 65 | class Event(Track): 66 | def __init__(self, 67 | data: np.ndarray, 68 | time: np.ndarray, 69 | fs: int, 70 | name: str=None, 71 | attrs: dict=None): 72 | if data is None: 73 | data = np.ones((len(time), 1), dtype=bool) 74 | assert data.ndim == 2 75 | assert data.shape[1] == 1 76 | assert time.ndim == 1 77 | assert np.all(time >= 0) 78 | assert np.all(np.diff(time) > 0) # monotonically increasing 79 | assert len(time) == len(data) # format is time-position and data-label associated with it 80 | Track.__init__(self, data, fs, coords={'time': time}, dims=('time', 'label'), name=name, attrs=attrs) 81 | 82 | 83 | class Segmentation(Track): 84 | def __init__(self, 85 | data: np.ndarray, 86 | time: np.ndarray, 87 | fs: int, 88 | name: str=None, 89 | attrs: dict=None): 90 | assert data.ndim == 2 91 | assert data.shape[1] == 1 92 | assert time.ndim == 1 93 | assert np.all(time >= 0) 94 | assert np.all(np.diff(time) > 0) # monotonically increasing 95 | if len(time) == len(data) + 1: 96 | data = np.r_[data, np.atleast_2d(data[-1])] # repeat last row 97 | # in practice, the last datum may be ignored 98 | elif len(time) != len(data): # format is time-position and data-label associated with time region on RIGHT 99 | raise ValueError 100 | assert len(time) == len(data) # format is time-position and data-label associated with it 101 | Track.__init__(self, data, fs, coords={'time': time}, dims=('time', 'label'), name=name, attrs=attrs) 102 | 103 | 104 | 105 | 106 | dat = np.arange(10).reshape(-1, 1) + 10 # should be in format time x value = N x 1 107 | wav = Signal(dat, 8000, 108 | name='Fun Wave', 109 | attrs={'unit': 'int16', 110 | 'min': -32768, # could exceed extremes of data 111 | 'max': 32767, 112 | 'path': 'here'} # path to file 113 | ) 114 | 115 | frm = Signal(np.random.randn(10, 4), 8000, 116 | name='frames') # frames, example of 2D signal 117 | 118 | tmv = Signal(dat, 8000, 119 | coords=(('time', np.arange(len(dat)) * 2 + 1), 120 | ('frequency', np.array([0]), {'unit': 'Hz'})), # a bit awkward here if we want to specify a unit 121 | attrs={'min': 0, # could exceed extremes of data 122 | 'max': 300, 123 | 'path': 'here'} # path to file 124 | ) # time-value 125 | 126 | img = Signal(np.random.randn(10, 4), 8000, 127 | coords=(('time', np.arange(10) + 1), 128 | ('frequency', np.linspace(0, 8000, 4), {'unit': 'Hz'})), 129 | name='spectrogram') # image 130 | 131 | 132 | dat = np.array(list("abcd")).reshape(-1, 1) 133 | seg = Segmentation(dat, np.arange(len(dat) + 1) * 10, 8000) 134 | 135 | evt = Event(None, np.arange(10), 8000) 136 | print(evt) 137 | 138 | -------------------------------------------------------------------------------- /timeview/gui/TimeView.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimeViewers/timeview/27c1ba3d468ee3a11a60006fbd4b7f9ada0e61e3/timeview/gui/TimeView.icns -------------------------------------------------------------------------------- /timeview/gui/TimeView.qch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimeViewers/timeview/27c1ba3d468ee3a11a60006fbd4b7f9ada0e61e3/timeview/gui/TimeView.qch -------------------------------------------------------------------------------- /timeview/gui/TimeView.qhc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimeViewers/timeview/27c1ba3d468ee3a11a60006fbd4b7f9ada0e61e3/timeview/gui/TimeView.qhc -------------------------------------------------------------------------------- /timeview/gui/__init__.py: -------------------------------------------------------------------------------- 1 | from . import dialogs, display_panel, extra, model, plot_area, rendering,\ 2 | view_table, viewer 3 | 4 | from .viewer import TimeView -------------------------------------------------------------------------------- /timeview/gui/dialogs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Dict, Tuple, Union, Optional 3 | import time 4 | 5 | from qtpy import QtWidgets, QtCore, QtGui, QtHelp 6 | from qtpy.QtCore import Slot, Signal 7 | 8 | from .rendering import Renderer 9 | from ..dsp.tracking import Track 10 | from ..dsp import processing 11 | 12 | logger = logging.getLogger() 13 | logger.setLevel(logging.DEBUG) 14 | 15 | 16 | class ProcessingError(Exception): 17 | pass 18 | 19 | 20 | class RenderDialog(QtWidgets.QDialog): 21 | 22 | def __init__(self, display_panel, renderer: Renderer): 23 | super().__init__() 24 | self.renderer = renderer 25 | self.panel = display_panel 26 | self.setWindowTitle(f'Render Parameter Entry for {renderer.name}') 27 | self.button_box =\ 28 | QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | 29 | QtWidgets.QDialogButtonBox.Cancel) 30 | self.button_box.accepted.connect(self.checkValues) 31 | self.button_box.rejected.connect(self.reject) 32 | 33 | self.parameters: Dict[str, str] = {} 34 | self.parameter_layout: Dict[str, QtWidgets.QLineEdit] = {} 35 | 36 | main_layout = QtWidgets.QVBoxLayout() 37 | self.formGroupBox = QtWidgets.QGroupBox("Parameters") 38 | self.createParameterLayout() 39 | 40 | main_layout.addWidget(self.formGroupBox) 41 | main_layout.addWidget(self.button_box) 42 | self.setLayout(main_layout) 43 | self.setWindowTitle(f'Parameter Entry for {renderer.name} Renderer') 44 | 45 | def createParameterLayout(self): 46 | layout = QtWidgets.QFormLayout() 47 | for parameter, value in self.renderer.get_parameters().items(): 48 | self.parameter_layout[parameter] = QtWidgets.QLineEdit() 49 | self.parameter_layout[parameter].setText(value) 50 | layout.addRow(QtWidgets.QLabel(parameter), 51 | self.parameter_layout[parameter]) 52 | self.formGroupBox.setLayout(layout) 53 | 54 | def checkValues(self): 55 | for parameter, entry in self.parameter_layout.items(): 56 | user_input = entry.text().rstrip(']) ').lstrip('([ ') 57 | logging.debug(f'For entry: {parameter} \t' 58 | f'user entered: {entry.text()} \t' 59 | f'saving as: {user_input}') 60 | self.parameters[parameter] = user_input 61 | try: 62 | # TODO: only pass the parameters that have been changed! 63 | self.renderer.set_parameters(self.parameters) 64 | except Exception as e: # TODO: what kind of exception? 65 | logging.exception("Invalid Parameter Error") 66 | logging.exception(e) 67 | raise # TODO: handle Invalid Parameter Exception 68 | self.accept() 69 | 70 | 71 | class ProcessingDialog(QtWidgets.QDialog): 72 | relay_generated_tracks = Signal(tuple, name='relay_tracks') 73 | 74 | def __init__(self, 75 | parent, 76 | processor: processing.Processor): 77 | super().__init__(parent) 78 | self.setModal(False) 79 | self.parent = parent 80 | self.processor = processor 81 | self.abort_process = False 82 | self.setWindowTitle(f'Track and Parameter Entry for {processor.name}') 83 | self.buttonBox = QtWidgets.QDialogButtonBox() 84 | 85 | self.rejectButton = QtWidgets.QPushButton('Cancel') 86 | self.acceptButton = QtWidgets.QPushButton('Process') 87 | self.buttonBox.addButton(self.acceptButton, 88 | QtWidgets.QDialogButtonBox.AcceptRole) 89 | self.buttonBox.addButton(self.rejectButton, 90 | QtWidgets.QDialogButtonBox.RejectRole) 91 | self.buttonBox.accepted.connect(self.preAccept) 92 | self.buttonBox.rejected.connect(self.reject) 93 | self.acceptButton.setEnabled(False) 94 | 95 | self.relay_generated_tracks.connect(self.parent.insert_processed_tracks) 96 | 97 | # {'wave': {test-mwm.wav: tracking.Wave()}} 98 | self.tracks: Dict[str, Dict[str, Track]] = {} 99 | self.parameters: Dict[str, str] = {} 100 | 101 | self.trackGroupBox = QtWidgets.QGroupBox("Track Selection") 102 | self.parameterGroupBox = QtWidgets.QGroupBox('Parameter Selection') 103 | 104 | self.track_layout: Dict[QtWidgets.QComboBox, str] = {} 105 | self.parameter_layout: Dict[str, QtWidgets.QLineEdit] = {} 106 | 107 | self.process_bar = QtWidgets.QProgressBar() 108 | self.process_bar.setRange(0, 100) 109 | self.process_bar.hide() 110 | 111 | self.createLayout() 112 | self.checkCurrentSelections() 113 | 114 | self.processor_thread = None 115 | 116 | def checkCurrentSelections(self): 117 | current_selected_track = self.parent.getSelectedTrack() 118 | for track_type in self.processor.acquire.values(): 119 | for combo_box in self.track_layout.keys(): 120 | if combo_box.count() == 1: 121 | combo_box.setCurrentIndex(0) 122 | elif isinstance(current_selected_track, track_type): 123 | combo_box.setCurrentText(current_selected_track.path.name) 124 | 125 | def createLayout(self): 126 | self.createTrackLayout() 127 | self.createParameterLayout() 128 | main_layout = QtWidgets.QVBoxLayout() 129 | main_layout.addWidget(self.trackGroupBox) 130 | main_layout.addWidget(self.parameterGroupBox) 131 | main_layout.addWidget(self.buttonBox, alignment=QtCore.Qt.AlignHCenter) 132 | main_layout.addWidget(self.process_bar) 133 | self.setLayout(main_layout) 134 | 135 | def createTrackLayout(self): 136 | layout = QtWidgets.QFormLayout() 137 | for track_name, track_type in self.processor.acquire.items(): 138 | combo_box = QtWidgets.QComboBox() 139 | 140 | tracks = [view.track 141 | for panel in self.parent.model.panels 142 | for view in panel.views 143 | if isinstance(view.track, track_type)] 144 | track_name_dict: Dict[str, Track] = {track.path.name: track 145 | for track in tracks} 146 | self.tracks[track_name] = track_name_dict 147 | combo_box.addItems(track_name_dict.keys()) 148 | combo_box.setCurrentIndex(-1) 149 | combo_box.currentIndexChanged.connect(self.setData) 150 | self.track_layout[combo_box] = track_name 151 | layout.addRow(QtWidgets.QLabel(track_name), 152 | combo_box) 153 | self.trackGroupBox.setLayout(layout) 154 | 155 | @Slot(name='setData') 156 | def setData(self): 157 | data: Optional[Dict[str, processing.Tracks]] = {} 158 | for combo_box, track_type in self.track_layout.items(): 159 | track_name = combo_box.currentText() 160 | if track_name == '': 161 | logging.debug(f'{track_type} value entered is empty') 162 | return 163 | track = self.tracks[track_type][track_name] 164 | data[track_type] = track 165 | 166 | try: 167 | self.processor.set_data(data) 168 | except processing.InvalidDataError: 169 | logging.exception(f'Invalid data being passed to ' 170 | f'{self.processor}.set_data()') 171 | raise 172 | parameters = self.processor.get_parameters() 173 | for parameter, value in parameters.items(): 174 | self.parameter_layout[parameter].setText(value) 175 | self.parameter_layout[parameter].setReadOnly(False) 176 | self.acceptButton.setEnabled(True) 177 | 178 | def createParameterLayout(self): 179 | layout = QtWidgets.QFormLayout() 180 | for parameter, value in self.processor.get_parameters().items(): 181 | self.parameter_layout[parameter] = QtWidgets.QLineEdit() 182 | self.parameter_layout[parameter].setText(value) 183 | self.parameter_layout[parameter].setReadOnly(True) 184 | layout.addRow(QtWidgets.QLabel(parameter), 185 | self.parameter_layout[parameter]) 186 | self.parameterGroupBox.setLayout(layout) 187 | 188 | def preAccept(self): 189 | parameters = {parameter: line_edit.text() 190 | for parameter, line_edit 191 | in self.parameter_layout.items()} 192 | try: 193 | self.processor.set_parameters(parameters) 194 | except processing.InvalidParameterError: 195 | logging.exception(f'Invalid Parameter entered') 196 | raise 197 | self.startThread() 198 | 199 | def startThread(self): 200 | self.abort_process = False 201 | self.acceptButton.setEnabled(False) 202 | self.process_bar.show() 203 | self.process_bar.setValue(0) 204 | self.processor_thread = ProcessorThread(self.processor, 205 | self.processor_finished, 206 | self.update_process_bar) 207 | self.parent.application.qtapp.aboutToQuit.connect(self.processor_thread.quit) 208 | self.buttonBox.rejected.disconnect() 209 | self.buttonBox.rejected.connect(self.abort) 210 | self.processor_thread.start() 211 | 212 | @Slot(int, name='update_process_bar') 213 | def update_process_bar(self, value: int): 214 | if self.abort_process: 215 | return 216 | self.process_bar.setValue(value) 217 | 218 | @Slot(name='processor_terminated') 219 | def abort(self): 220 | self.abort_process = True 221 | logging.debug('Sending quit signal to processor thread') 222 | self.processor_thread.quit() 223 | self.processor_thread.abort = True 224 | self.process_bar.reset() 225 | # self.rejectButton.setText('Terminate!') 226 | # self.buttonBox.rejected.disconnect() 227 | # self.buttonBox.rejected.connect(self.processor_thread.quit) 228 | # while self.processor_thread.isRunning(): 229 | # logger.info('Waiting for thread to quit...') 230 | # time.sleep(.1) 231 | # logging.info('Processor thread quit without being terminated') 232 | # self.rejectButton.setText('Cancel') 233 | self.acceptButton.setEnabled(True) 234 | self.buttonBox.rejected.disconnect() 235 | self.buttonBox.rejected.connect(self.reject) 236 | 237 | @Slot(tuple, name='processor_finished') 238 | def processor_finished(self, new_tracks): 239 | logger.info('processor finished') 240 | logger.info(f'abort status: {self.abort_process}') 241 | if self.abort_process: 242 | self.reject() 243 | self.close() 244 | else: 245 | self.relay_generated_tracks.emit(new_tracks) 246 | self.accept() 247 | 248 | 249 | class About(QtWidgets.QMessageBox): 250 | def __init__(self): 251 | super().__init__() 252 | # You cannot easily resize a QMessageBox 253 | self.setText('© Copyright 2009-2017, TimeView Developers') 254 | self.setStandardButtons(QtWidgets.QMessageBox.Ok) 255 | self.setDefaultButton(QtWidgets.QMessageBox.Ok) 256 | self.setWindowTitle('About Timeview') 257 | 258 | 259 | class Bug(QtWidgets.QDialog): 260 | def __init__(self, app, exc_type, exc_value, exc_traceback): 261 | super().__init__() 262 | self.app = app 263 | import traceback 264 | self.setWindowTitle('Bug Report') 265 | traceback_list = traceback.format_exception(exc_type, exc_value, exc_traceback) 266 | self.traceback = ''.join([element.rstrip() + '\n' for element in traceback_list]) 267 | self.buttonBox = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok) 268 | self.buttonBox.accepted.connect(self.accept) 269 | copyButton = QtWidgets.QPushButton('Copy To Clipboard') 270 | copyButton.pressed.connect(self.copyToClipboard) 271 | self.buttonBox.addButton(copyButton, QtWidgets.QDialogButtonBox.ApplyRole) 272 | 273 | main_layout = QtWidgets.QVBoxLayout() 274 | self.textEdit = QtWidgets.QTextEdit() 275 | self.textEdit.setLineWrapMode(0) 276 | self.textEdit.setText(self.traceback.replace('\n', '\r')) 277 | self.textEdit.setReadOnly(True) 278 | 279 | main_layout.addWidget(self.textEdit) 280 | main_layout.addWidget(self.buttonBox) 281 | self.setFixedWidth(self.textEdit.width() + 282 | main_layout.getContentsMargins()[0] + 283 | main_layout.getContentsMargins()[2]) 284 | self.setLayout(main_layout) 285 | 286 | def copyToClipboard(self): 287 | text = "```\r" + self.textEdit.toPlainText() + "```" 288 | cb = self.app.clipboard() 289 | cb.setText(text) 290 | 291 | 292 | class HelpBrowser(QtWidgets.QTextBrowser): 293 | def __init__(self, help_engine: QtHelp.QHelpEngine, parent: QtWidgets.QWidget=None): 294 | super().__init__(parent) 295 | self.help_engine = help_engine 296 | 297 | def loadResource(self, typ: int, name: QtCore.QUrl): 298 | if name.scheme() == 'qthelp': 299 | return QtCore.QVariant(self.help_engine.fileData(name)) 300 | else: 301 | return super().loadResource(typ, name) 302 | 303 | 304 | class InfoDialog(QtWidgets.QMessageBox): 305 | def __init__(self, info): 306 | super().__init__() 307 | info_string = info 308 | self.setText(info_string) 309 | self.setStandardButtons(QtWidgets.QMessageBox.Ok) 310 | self.setDefaultButton(QtWidgets.QMessageBox.Ok) 311 | self.setWindowTitle('Track info') 312 | 313 | 314 | class ProgressTracker(QtCore.QObject): 315 | progress = Signal(int) 316 | 317 | def update(self, value): 318 | self.progress.emit(value) 319 | 320 | 321 | # look at example here: 322 | # https://stackoverflow.com/questions/25108321/how-to-return-value-from-function-running-by-qthread-and-queue 323 | class ProcessorThread(QtCore.QThread): 324 | finished = Signal(tuple, name='finished') 325 | 326 | def __init__(self, 327 | processor: processing.Processor, 328 | callback, 329 | update_process_bar): 330 | super().__init__() 331 | self.finished.connect(callback) 332 | self.processor = processor 333 | self.abort = False 334 | self.progressTracker = ProgressTracker() 335 | self.progressTracker.progress.connect(update_process_bar) 336 | 337 | def __del__(self): 338 | self.wait() 339 | 340 | def process(self) -> Tuple[Union[processing.Tracks]]: 341 | try: 342 | new_track_list = self.processor.process(progressTracker=self.progressTracker) 343 | except Exception as e: 344 | logging.exception("Processing Error") 345 | logging.exception(e) 346 | self.exit() 347 | # TODO: how do we want to handle processing errors? 348 | raise ProcessingError 349 | else: 350 | self.progressTracker.update(100) 351 | return new_track_list 352 | 353 | def run(self): 354 | while not self.abort: 355 | new_track_list = self.process() 356 | if self.abort: 357 | self.quit() 358 | self.finished.emit(new_track_list) 359 | self.abort = True 360 | self.quit() 361 | -------------------------------------------------------------------------------- /timeview/gui/display_panel.py: -------------------------------------------------------------------------------- 1 | # stdlib 2 | import logging 3 | from typing import List, Optional 4 | from operator import sub 5 | 6 | # 3rd party 7 | from qtpy import QtCore, QtGui, QtWidgets 8 | from qtpy.QtCore import Slot, Signal 9 | 10 | # our modules 11 | from .model import Panel, View 12 | from .plot_area import DumbPlot 13 | from .view_table import ViewTable 14 | 15 | logger = logging.getLogger() 16 | logger.setLevel(logging.DEBUG) 17 | 18 | icon_color = QtGui.QColor('#00897B') 19 | 20 | 21 | class Spacer(QtWidgets.QWidget): 22 | 23 | def __init__(self): 24 | super().__init__() 25 | self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, 26 | QtWidgets.QSizePolicy.Expanding) 27 | 28 | 29 | class Handle(Spacer): 30 | 31 | def __init__(self, parent, label: str='test'): 32 | super().__init__() 33 | self.setParent(parent) 34 | self.setFixedWidth(30) 35 | self.label = QtWidgets.QLabel() 36 | self.layout = QtWidgets.QVBoxLayout() 37 | self.layout.setContentsMargins(0, 0, 0, 0) 38 | self.layout.addWidget(self.label, 0, QtCore.Qt.AlignHCenter) 39 | self.setLayout(self.layout) 40 | self.label.setText(str(label)) 41 | 42 | @Slot(name='update_label') 43 | def updateLabel(self): 44 | panel_obj = self.parent().panel 45 | if panel_obj is None: 46 | return 47 | index = self.parent().main_window.model.panels.index(panel_obj) 48 | self.label.setText(str(index + 1)) 49 | 50 | 51 | class TableSplitter(QtWidgets.QSplitter): 52 | position = Signal(list, name='position') 53 | 54 | def __init__(self, parent): 55 | super().__init__() 56 | self.setParent(parent) 57 | self.setOrientation(QtCore.Qt.Horizontal) 58 | self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, 59 | QtWidgets.QSizePolicy.Expanding) 60 | self.setHandleWidth(10) 61 | self.is_collapsed = False 62 | self.old_size = 0 63 | self.setStyleSheet("QSplitter::handle{background: darkgray;}") 64 | self.position.connect(self.parent().main_window.setSplitter) 65 | self.splitterMoved.connect(self.moveFinished) 66 | 67 | def eventFilter(self, source: QtCore.QObject, event: QtCore.QEvent) \ 68 | -> QtWidgets.QWidget.eventFilter: 69 | if event.type() == QtCore.QEvent.MouseButtonDblClick: 70 | self.is_collapsed = ~self.is_collapsed 71 | self.showOrHideChild() 72 | return QtWidgets.QWidget.eventFilter(self, source, event) 73 | 74 | def showOrHideChild(self): 75 | if self.is_collapsed: 76 | self.old_size = self.sizes()[1] 77 | self.setSizes([1, 0]) 78 | else: 79 | self.setSizes([1, self.old_size]) 80 | self.moveFinished() 81 | 82 | @Slot(int, int, name='moveFinished') 83 | def moveFinished(self): 84 | self.position.emit([1, self.sizes()[1]]) 85 | 86 | @Slot(list, name='setSizes') 87 | def setSizes_(self, sizes: List[int]): 88 | self.setSizes(sizes) 89 | 90 | 91 | class Frame(QtWidgets.QFrame): 92 | select_me = Signal(QtWidgets.QFrame, name='select_me') 93 | select_previous = Signal(name='select_previous') 94 | select_next = Signal(name='select_next') 95 | move_me = Signal(QtWidgets.QFrame, name='move_me') 96 | move_up = Signal(name='move_up') 97 | move_down = Signal(name='move_down') 98 | insert_here = Signal(QtWidgets.QFrame, name='insert_here') 99 | 100 | def __init__(self, main_window, *args, **kwargs): 101 | super().__init__(*args, **kwargs) 102 | self.setParent(main_window) 103 | self.application = main_window.application 104 | self.setContentsMargins(0, 0, 0, 0) 105 | self.setFrameStyle(self.NoFrame) 106 | 107 | # Layout 108 | self.layout = QtWidgets.QVBoxLayout() 109 | self.layout.setContentsMargins(0, 0, 0, 0) 110 | self.layout.setSpacing(0) 111 | self.setLayout(self.layout) 112 | 113 | # Focus 114 | self.installEventFilter(self) 115 | self.setFocusPolicy(QtCore.Qt.StrongFocus) 116 | 117 | # Sizing 118 | self.n = self.application.config['panel_height'] 119 | self.updateHeight() 120 | 121 | # Drag and Drop Related 122 | # TODO: only accept drag/drop of other Frames (through MIME data) 123 | self.setAcceptDrops(True) 124 | self.displayPanel: DisplayPanel = None 125 | self.dragStartPos = QtCore.QPoint() 126 | self.drag = None 127 | self.resetStyle() 128 | 129 | # Signals 130 | self.select_me.connect(self.parent().selectFrame, 131 | QtCore.Qt.UniqueConnection) 132 | self.move_me.connect(self.parent().frameToMove) 133 | self.select_next.connect(self.parent().selectNext) 134 | self.select_previous.connect(self.parent().selectPrevious) 135 | self.insert_here.connect(self.parent().whereToInsert) 136 | 137 | def resetStyle(self): 138 | self.setStyleSheet(""" 139 | Frame { 140 | border-width: 3px; 141 | border-color: transparent; 142 | border-style: solid; 143 | } 144 | """) 145 | 146 | def minimumSizeHint(self) -> QtCore.QSize: 147 | return QtCore.QSize(800, 400) # TODO: read from config file? 148 | 149 | def sizeHint(self) -> QtCore.QSize: 150 | return QtCore.QSize(1200, 500) # TODO: read from config file? (One or the other) 151 | 152 | def increaseSize(self, increment: int=50): 153 | self.n += increment 154 | self.updateHeight() 155 | 156 | def decreaseSize(self, increment: int=50): 157 | self.n -= increment 158 | if self.n < 100: # TODO: from config? 159 | self.n = 100 160 | self.updateHeight() 161 | 162 | def updateHeight(self): 163 | self.setFixedHeight(self.n) 164 | self.application.config['panel_height'] = self.n 165 | 166 | def eventFilter(self, source: QtCore.QObject, event: QtCore.QEvent): 167 | if event.type() == QtCore.QEvent.MouseButtonPress: 168 | self.select_me.emit(self) 169 | return QtWidgets.QWidget.eventFilter(self, source, event) 170 | 171 | def mousePressEvent(self, event: QtGui.QMouseEvent): 172 | if event.button() == QtCore.Qt.LeftButton: 173 | self.dragStartPos = event.pos() 174 | 175 | def mouseMoveEvent(self, event: QtGui.QMouseEvent): 176 | if event.buttons() != QtCore.Qt.LeftButton: 177 | event.ignore() 178 | return 179 | if (sub(event.pos(), self.dragStartPos)).manhattanLength() < \ 180 | QtWidgets.QApplication.startDragDistance(): 181 | event.ignore() 182 | return 183 | self.move_me.emit(self) 184 | mime_data = QtCore.QMimeData() 185 | mime_data.setObjectName('frame') 186 | drag = QtGui.QDrag(self) 187 | drag.setMimeData(mime_data) 188 | drop_action = drag.exec_(QtCore.Qt.MoveAction) 189 | 190 | def dropEvent(self, event: QtGui.QDropEvent): 191 | if event.mimeData().objectName() == 'frame': 192 | self.resetStyle() 193 | self.insert_here.emit(self) 194 | event.setDropAction(QtCore.Qt.MoveAction) 195 | event.accept() 196 | 197 | def dragEnterEvent(self, event: QtGui.QDragEnterEvent): 198 | if event.mimeData().objectName() == 'frame': 199 | event.accept() 200 | 201 | def dragLeaveEvent(self, event: QtGui.QDragLeaveEvent): 202 | self.resetStyle() 203 | event.accept() 204 | 205 | 206 | class DisplayPanel(QtWidgets.QWidget): 207 | select_me = Signal(QtWidgets.QFrame, name='select_me') 208 | add_new_view = Signal(name='add_new_view') 209 | hideView = Signal(View, name='hideView') 210 | showView = Signal(View, int, name='showView') 211 | selectionChanged = Signal(View, name='selectionChanged') 212 | rendererChanged = Signal(View, name='updateView') 213 | changeColor = Signal(View, name='changeColor') 214 | plotViewObj = Signal(View, name='plotViewObj') 215 | viewMoved = Signal(int, name='viewMoved') 216 | 217 | def __init__(self, 218 | frame: Frame): 219 | super().__init__() 220 | layout = QtWidgets.QHBoxLayout() 221 | layout.setContentsMargins(1, 1, 1, 1) 222 | self.setLayout(layout) 223 | # Placeholder for item reference 224 | self.panel: Optional[Panel] = None 225 | self.setParent(frame) 226 | self.select_me.connect(frame.select_me) 227 | self.main_window = self.parent().parent() # can't static type this because can't import viewer.py (circular) 228 | self.view_table = ViewTable(self, self.main_window.column_width_hint) 229 | 230 | # View Table 231 | self.view_table.installEventFilter(self) 232 | 233 | # Splitter 234 | self.pw = DumbPlot(self) 235 | self.table_splitter = TableSplitter(self) 236 | self.table_splitter.addWidget(self.pw) 237 | self.table_splitter.addWidget(self.view_table) 238 | self.table_splitter.setStretchFactor(0, 1) 239 | self.table_splitter.setStretchFactor(1, 0) 240 | self.table_splitter.setCollapsible(0, True) 241 | self.table_splitter.handle(1).installEventFilter(self.table_splitter) 242 | self.view_table.setMaximumWidth(self.view_table.viewportSizeHint().width()) 243 | 244 | # Plot area 245 | self.hideView.connect(self.pw.hideView) 246 | self.showView.connect(self.pw.showView) 247 | self.selectionChanged.connect(self.pw.selectionChanged) 248 | self.rendererChanged.connect(self.pw.rendererChanged) 249 | self.changeColor.connect(self.pw.changeColor) 250 | self.plotViewObj.connect(self.pw.addView) 251 | self.viewMoved.connect(self.main_window.viewMoved) 252 | self.handle = Handle(self, label='') 253 | layout.addWidget(self.handle) 254 | layout.addWidget(self.table_splitter) 255 | self.add_new_view.connect(self.main_window.guiAddView) 256 | 257 | # def play(self): 258 | # print('starting play()') 259 | # track = self.getCurrentTrack() 260 | # if not track: 261 | # return 262 | # import simpleaudio as sa 263 | # # TODO: eventually write a whole QT audio player? 264 | # try: 265 | # play_obj = sa.play_buffer(track.value, 1, 2, track.fs) 266 | # play_obj.wait_done() # this will block 267 | # except Exception: 268 | # pass 269 | 270 | def setButtonEnableStatus(self): 271 | # TODO: reroute to main window method to enable/disable track items 272 | pass 273 | # if self.panel.selected_view: 274 | # self.view_control.enableButtonsNeedingView() 275 | # else: 276 | # self.view_control.disableButtonsNeedingView() 277 | 278 | def loadPanel(self, panel: Panel): 279 | assert isinstance(panel, Panel) 280 | self.panel = panel 281 | self.handle.updateLabel() 282 | self.view_table.loadPanel(panel) 283 | self.main_window.evalTrackMenu() 284 | 285 | @Slot(name='setSplitterPosition') 286 | def setSplitterPosition(self): 287 | current_sizes = self.table_splitter.sizes() 288 | if sum(current_sizes) == 0: 289 | return 290 | table_width = self.view_table.viewportSizeHint().width() 291 | self.table_splitter.setSizes([1, table_width]) 292 | 293 | def eventFilter(self, source: QtCore.QObject, event: QtCore.QEvent) \ 294 | -> QtWidgets.QWidget.eventFilter: 295 | if event.type() == QtCore.QEvent.FocusIn: 296 | self.select_me.emit(self.parent()) 297 | self.panel.select_me() 298 | return QtWidgets.QWidget.eventFilter(self, source, event) 299 | 300 | def createViewWithTrack(self, 301 | track, 302 | renderer_name: Optional[str]=None, 303 | **kwargs): 304 | if 'renderer' in kwargs.keys(): 305 | renderer_name = kwargs.pop('renderer') 306 | new_view = self.panel.new_view(track, 307 | renderer_name=renderer_name, 308 | **kwargs) 309 | self.view_table.addView(new_view, setColor='color' not in kwargs) 310 | self.main_window.evalTrackMenu() 311 | self.main_window.resetEnabledProcessors() 312 | 313 | def removeViewFromChildren(self, view_to_remove): 314 | """this method removes the view from the child widgets""" 315 | self.pw.removeView(view_to_remove) 316 | self.view_table.delView(view_to_remove) 317 | 318 | def delViewFromModel(self, view_to_remove): 319 | del_index = self.panel.views.index(view_to_remove) 320 | self.panel.remove_view(pos=del_index) 321 | 322 | @Slot(View, int, name='moveView') 323 | def moveView(self, view_to_move: View, panel_index: int): 324 | assert(isinstance(view_to_move, View)) 325 | self.removeViewFromChildren(view_to_move) 326 | destination_panel = self.determineDestination(panel_index) 327 | self.main_window.model.move_view_across_panel(view_to_move, 328 | destination_panel) 329 | self.finishViewOperation(view_to_move, panel_index) 330 | 331 | @Slot(View, int, name='linkTrack') 332 | def linkTrack(self, view_to_link, panel_index): 333 | self.selectView(view_to_link) 334 | destination_panel = self.determineDestination(panel_index) 335 | self.main_window.model.link_track_across_panel(view_to_link, 336 | destination_panel) 337 | self.finishViewOperation(view_to_link, panel_index) 338 | 339 | def copyView(self, view_to_copy, panel_index): 340 | self.selectView(view_to_copy) 341 | destination_panel = self.determineDestination(panel_index) 342 | self.main_window.model.copy_view_across_panel(view_to_copy, 343 | destination_panel) 344 | self.finishViewOperation(view_to_copy, panel_index) 345 | 346 | def finishViewOperation(self, view, panel_index): 347 | self.viewMoved.emit(panel_index) 348 | view_range = view.renderer.vb.viewRange() 349 | self.main_window.model.panels[panel_index].views[-1].renderer.vb.setRange(xRange=view_range[0], 350 | yRange=view_range[1], 351 | padding=0) 352 | self.main_window.evalTrackMenu() 353 | 354 | def selectView(self, view): 355 | self.view_table.selectRow(self.panel.views.index(view)) 356 | 357 | def determineDestination(self, panel_index: int) -> Panel: 358 | if 0 <= panel_index < len(self.main_window.model.panels): 359 | destination_panel = self.main_window.model.panels[panel_index] 360 | elif panel_index == -1: 361 | insert_index = len(self.main_window.model.panels) 362 | self.main_window.createNewPanel(pos=insert_index) 363 | destination_panel = self.main_window.model.panels[-1] 364 | self.main_window.applySync() 365 | else: 366 | raise IndexError 367 | return destination_panel 368 | -------------------------------------------------------------------------------- /timeview/gui/extra.py: -------------------------------------------------------------------------------- 1 | from qtpy import QtWidgets, QtGui 2 | 3 | 4 | class Widget(QtWidgets.QWidget): 5 | 6 | def __init__(self, text: str="", panel_info=None): 7 | super().__init__() 8 | layout = QtWidgets.QVBoxLayout() 9 | self.label = QtWidgets.QLabel(f"Hello, I'm {text}!") 10 | layout.addWidget(self.label) 11 | self.setLayout(layout) 12 | font = QtGui.QFont() 13 | font.setPointSize(16) 14 | self.label.setFont(font) 15 | self.setSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding, 16 | QtWidgets.QSizePolicy.MinimumExpanding) 17 | self.setStyleSheet(""" 18 | QWidget { 19 | padding: 0px; 20 | margin: 0px; 21 | background: darkGreen; 22 | } 23 | """) 24 | 25 | 26 | class Spacer(QtWidgets.QWidget): 27 | 28 | def __init__(self): 29 | super().__init__() 30 | self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, 31 | QtWidgets.QSizePolicy.Expanding) 32 | -------------------------------------------------------------------------------- /timeview/gui/model.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import List, Optional, Tuple, DefaultDict 3 | from copy import deepcopy 4 | 5 | from . import rendering 6 | from ..dsp import tracking 7 | 8 | 9 | class UnknownRendererError(Exception): 10 | pass 11 | 12 | 13 | class View(object): 14 | track2renderers = {t.__name__: 15 | {r.name: r for r 16 | in rendering.get_renderer_classes(t)} 17 | for t in tracking.get_track_classes()} 18 | 19 | def __init__(self, 20 | track: tracking.Track, 21 | attached_panel: 'Panel', 22 | renderer_name: Optional[str] = None, 23 | show: bool = True, 24 | color: Tuple[int, int, int] = (255, 255, 255), 25 | **parameters): 26 | self.track = track 27 | self.show = show 28 | self.panel = attached_panel 29 | self.renderer: Optional[rendering.Renderer] = None 30 | self.y_bounds = (0, 1) 31 | self.change_renderer(renderer_name if renderer_name is not None 32 | else next( 33 | iter(self.track2renderers[ 34 | type(self.track).__name__])), 35 | **parameters) 36 | self.color = color 37 | 38 | def __str__(self) -> str: 39 | return f"{id(self)} (with track: {id(self.track)} " \ 40 | f"- {self.track.path} - {self.renderer})" 41 | 42 | def set_color(self, color): 43 | self.color = color 44 | 45 | def change_panel(self, panel): 46 | self.panel = panel 47 | 48 | def change_renderer(self, renderer_name: str, **parameters): 49 | # TODO: way way hackey, reconsider alternate method 50 | if isinstance(self.renderer, rendering.Spectrogram): 51 | self.renderer.prepareForDeletion() 52 | try: 53 | self.renderer =\ 54 | self.track2renderers[ 55 | type(self.track).__name__][renderer_name](**parameters) 56 | except KeyError: 57 | raise UnknownRendererError 58 | self.renderer.set_view(self, **parameters) 59 | 60 | def is_selected(self): 61 | return self.panel.selected_view is self 62 | 63 | def set_selected(self): 64 | self.panel.set_selected_view(self) 65 | 66 | 67 | class Panel(object): 68 | def __init__(self, model): 69 | self.views: List['View'] = [] 70 | self._selected_view: Optional[View] = None 71 | self.model = model 72 | 73 | def __str__(self) -> str: 74 | return str(id(self)) 75 | 76 | def new_view(self, 77 | track: tracking.Track, 78 | renderer_name: Optional[str]=None, 79 | show: bool=True, 80 | color: Tuple[int, int, int]=(255, 255, 255), 81 | pos: Optional[int]=None, 82 | **parameters) -> View: 83 | if not pos: 84 | pos = len(self.views) 85 | self.views.insert(pos, View(track, 86 | self, 87 | renderer_name=renderer_name, 88 | show=show, 89 | color=color, 90 | **parameters)) 91 | if pos == 0: 92 | self.selected_view = self.views[pos] 93 | return self.views[pos] 94 | 95 | def remove_view(self, pos: int) -> View: 96 | view_to_remove = self.views.pop(pos) 97 | if len(self.views) == 0: 98 | self.selected_view = None 99 | elif pos == len(self.views): 100 | self.selected_view = self.views[-1] 101 | else: 102 | self.selected_view = self.views[pos] 103 | # TODO: this is way too hackey, open to suggestions to alternatives 104 | if isinstance(view_to_remove.renderer, rendering.Spectrogram): 105 | view_to_remove.renderer.prepareForDeletion() 106 | return view_to_remove 107 | 108 | def move_view(self, to_index: int, from_index: int): 109 | self.views.insert(to_index, self.remove_view(from_index)) 110 | 111 | def get_selected_view(self): 112 | return self._selected_view 113 | 114 | def set_selected_view(self, selected_view): 115 | if self.views: 116 | assert selected_view in self.views 117 | else: 118 | assert selected_view is None 119 | self._selected_view = selected_view 120 | 121 | selected_view = property(get_selected_view, set_selected_view) 122 | 123 | def selected_track(self) -> tracking.Track: 124 | return self.selected_view.track 125 | 126 | def is_selected(self): 127 | return self is self.model.selected_panel 128 | 129 | def select_me(self): 130 | self.model.set_selected_panel(self) 131 | 132 | def get_max_duration(self) -> int: 133 | return max([view.track.duration / view.track.fs 134 | for view in self.views]) 135 | 136 | 137 | class Model(object): 138 | def __init__(self): 139 | self.panels: List[Panel] = [] 140 | self.panel_synchronization = True 141 | self.selected_panel: Panel = None 142 | 143 | def __str__(self) -> str: 144 | s = "" 145 | for panel in self.panels: 146 | for view in panel.views: 147 | s += f"panel: {panel} view: {view}\n" 148 | return s 149 | 150 | def set_selected_panel(self, panel): 151 | assert panel in self.panels 152 | self.selected_panel = panel 153 | 154 | def new_panel(self, pos: Optional[int] = None) -> Panel: 155 | if not pos: 156 | pos = len(self.panels) 157 | self.panels.insert(pos, Panel(model=self)) 158 | if len(self.panels) == 1: 159 | self.selected_panel = self.panels[pos] 160 | return self.panels[pos] 161 | 162 | def remove_panel(self, pos: int) -> Panel: 163 | assert bool(self.panels[pos]) 164 | if len(self.panels) == 1: 165 | self.selected_panel = None 166 | elif pos < len(self.panels) - 2: 167 | self.selected_panel = self.panels[pos + 1] 168 | elif pos == len(self.panels) - 1: 169 | self.selected_panel = self.panels[pos - 1] 170 | return self.panels.pop(pos) 171 | 172 | def move_panel(self, to_index: int, from_index: int): 173 | self.panels.insert(to_index, self.remove_panel(from_index)) 174 | 175 | def get_groups(self) -> DefaultDict[int, List[View]]: 176 | grp = defaultdict(list) 177 | for panel in self.panels: 178 | for view in panel.views: 179 | grp[id(view.track)].append(view) 180 | return grp 181 | 182 | def get_linked_views(self, view: View) -> List[View]: 183 | # includes the passed argument view 184 | return self.get_groups()[id(view.track)] 185 | 186 | def move_view_across_panel(self, 187 | view: View, 188 | to_panel: Panel): 189 | 190 | source_panel = self.get_source_panel(view) 191 | pos = source_panel.views.index(view) 192 | view = source_panel.remove_view(pos) 193 | view.panel = to_panel 194 | to_panel.views.append(view) 195 | 196 | @staticmethod 197 | def link_track_across_panel(view: View, 198 | to_panel: Panel): 199 | renderer_name = view.renderer.name 200 | new_view = to_panel.new_view(view.track, 201 | renderer_name, 202 | show=view.show, 203 | color=view.color) 204 | return new_view 205 | 206 | @staticmethod 207 | def copy_view_across_panel(view: View, 208 | to_panel: Panel): 209 | color = view.color 210 | track = deepcopy(view.track) 211 | renderer = view.renderer 212 | show = view.show 213 | new_view = to_panel.new_view(track, 214 | renderer.name, 215 | show=show, 216 | color=color) 217 | 218 | def get_source_panel(self, view: View): 219 | for panel in self.panels: 220 | if view in panel.views: 221 | return panel 222 | 223 | def move_view_within_panel(self, view: View, position: int): 224 | panel = self.get_source_panel(view) 225 | panel.views.remove(view) 226 | panel.views.insert(position, view) 227 | -------------------------------------------------------------------------------- /timeview/gui/plot_area.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Dict, Optional 3 | from math import ceil 4 | import sys 5 | 6 | from qtpy import QtGui, QtWidgets, QtCore 7 | from qtpy.QtCore import Slot, Signal 8 | import pyqtgraph as pg 9 | 10 | from .model import View 11 | from . import rendering 12 | 13 | logger = logging.getLogger() 14 | logger.setLevel(logging.DEBUG) 15 | 16 | icon_color = QtGui.QColor('#00897B') 17 | 18 | 19 | class DumbPlot(pg.GraphicsView): 20 | maxWidthChanged = Signal(name='maxWidthChanged') 21 | 22 | def __init__(self, display_panel): 23 | super().__init__() 24 | self.display_panel = display_panel 25 | self.main_window = self.display_panel.main_window 26 | # Layout 27 | self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, 28 | QtWidgets.QSizePolicy.Expanding) 29 | self.layout = pg.GraphicsLayout() 30 | self.layout.layout.setColumnFixedWidth(0, 31 | self.main_window.axis_width) 32 | 33 | # Variables 34 | self.axes: Optional[Dict[View, pg.AxisItem]] = {} 35 | self.vbs: Optional[Dict[View, pg.ViewBox]] = {} 36 | 37 | self.main_plot = pg.PlotItem(enableMenu=False) 38 | self.main_plot.hideButtons() 39 | self.main_plot.hideAxis('left') 40 | self.main_plot.hideAxis('bottom') 41 | self.main_vb: pg.ViewBox = self.main_plot.getViewBox() 42 | self.main_vb.sigXRangeChanged.connect(self.zoomChanged) 43 | self.main_vb.setXRange(0, 1) 44 | self.main_vb.setYRange(0, 1) 45 | self.main_vb.setMouseEnabled(False, False) 46 | self.main_plot.setZValue(-sys.maxsize - 1) 47 | self.axis_bottom = pg.AxisItem('bottom', parent=self.main_vb) 48 | self.axis_bottom.setLabel('time (s)') 49 | self.axis_bottom.showLabel(self.main_window.application.config['show_x-axis_label']) 50 | # self.axis_bottom.setFixedHeight(self.axis_bottom.height()) 51 | self.label = pg.LabelItem(justify='left', color=[255, 255, 255, 0]) 52 | 53 | # Connections 54 | self.maxWidthChanged.connect(self.main_window.checkAxesWidths) 55 | self.main_vb.sigResized.connect(self.updateViews) 56 | self.main_window.cursorReadoutStatus.connect(self.setCursorReadout) 57 | 58 | self.proxy = pg.SignalProxy(self.scene().sigMouseMoved, 59 | rateLimit=60, 60 | slot=self.mouseMoved) 61 | self.buildLayout() 62 | self.setCursorReadout(self.main_window.cursor_readout) 63 | 64 | def buildLayout(self): 65 | self.setCentralWidget(self.layout) 66 | self.layout.addItem(self.main_plot, row=0, col=1) 67 | self.layout.addItem(self.label, row=0, col=1) 68 | self.layout.addItem(self.axis_bottom, row=1, col=1) 69 | self.axis_bottom.linkToView(self.main_vb) 70 | self.layout.scene().sigMouseClicked.connect(self.onClick, 71 | QtCore.Qt.UniqueConnection) 72 | self.layout.layout.setRowStretchFactor(0, 1) 73 | self.layout.layout.setColumnStretchFactor(1, 1) 74 | self.layout.update() 75 | self.axis_bottom.hide() 76 | 77 | 78 | def wheelEvent(self, event: QtGui.QWheelEvent): 79 | super().wheelEvent(event) 80 | event.accept() 81 | 82 | def mouseMoved(self, event): 83 | if self.selected_view() not in self.vbs.keys(): 84 | return 85 | vb = self.vbs[self.selected_view()] 86 | pos = event[0] # using signal proxy turns original arguments into a tuple 87 | if not self.main_plot.sceneBoundingRect().contains(pos): 88 | return 89 | mousePoint = vb.mapSceneToView(pos) 90 | time = f"{mousePoint.x():12.8}" 91 | sample = int(ceil(mousePoint.x() * self.selected_view().track.fs)) 92 | y = f"{mousePoint.y():+12.8}" # always show sign 93 | self.label.setText( 94 | " " # TODO: is this doing anything? 95 | f"t = {time}
" 96 | f"x = {sample}
" 97 | f"y = {y}" 98 | f"
") 99 | 100 | @Slot(bool, name='Set Cursor Readout') 101 | def setCursorReadout(self, enabled): 102 | if enabled: 103 | self.label.show() 104 | else: 105 | self.label.hide() 106 | self.layout.update() 107 | 108 | def onClick(self, event): 109 | if event.double(): 110 | items = self.layout.scene().items(event.scenePos()) 111 | obj = items[0] 112 | 113 | # double clicked on infinite-line implies remove 114 | if isinstance(obj, pg.InfiniteLine): 115 | obj.parent.removeSegment(obj, modify_track=True) 116 | event.accept() 117 | return True 118 | # double click on view box implies add partition 119 | elif isinstance(obj, pg.ViewBox): 120 | partitions = [view.renderer for view in self.vbs.keys() 121 | if isinstance(view.renderer, 122 | rendering.PartitionEdit)] 123 | # no partitions found, exiting 124 | if not partitions: 125 | event.ignore() 126 | return False 127 | x = self.main_vb.mapFromItemToView(self.main_vb, 128 | event.pos()).x() 129 | # determine which partition view to add new partition to 130 | selected_view = self.selected_view() 131 | if len(partitions) == 1: 132 | partitions[0].insertSegment(x) 133 | event.accept() 134 | return True 135 | elif isinstance(selected_view.renderer, 136 | rendering.PartitionEdit): 137 | selected_view.renderer.insertSegment(x) 138 | event.accept() 139 | return True 140 | else: 141 | logger.info("Multiple Partition Views detected, " 142 | "none of which are selected.\n" 143 | "select the view you wish to insert the " 144 | "partition into") 145 | event.ignore() 146 | return False 147 | 148 | event.ignore() 149 | 150 | @Slot(View, name='rendererChanged') 151 | def rendererChanged(self, view: View): 152 | self.removeView(view) 153 | self.addView(view, forceRangeReset=False) 154 | 155 | @Slot(View, name='addView') 156 | def addView(self, view: View, forceRangeReset=None): 157 | logging.debug(f'Adding {view.renderer.name}') 158 | if forceRangeReset is not None: 159 | rangeReset = forceRangeReset 160 | else: 161 | if len(self.main_window.model.panels) == 1: 162 | rangeReset = not(bool(self.vbs)) 163 | else: 164 | rangeReset = False 165 | ax, vb = view.renderer.render(self) 166 | self.main_window.joinGroup(view) 167 | self.axes[view] = ax 168 | self.vbs[view] = vb 169 | self.updateWidestAxis() 170 | self.updateViews() 171 | if view.show is False: 172 | self.hideView(view) 173 | self.axis_bottom.show() 174 | # this fixes the bottom axis mirroring on macOS 175 | old_size = self.size() 176 | self.resize(QtCore.QSize(old_size.width()+1, old_size.height())) 177 | self.resize(old_size) 178 | 179 | self.layout.update() 180 | if isinstance(view.renderer, rendering.Spectrogram): 181 | view.renderer.generatePlotData() 182 | if rangeReset: 183 | self.main_window.zoomFit() 184 | 185 | # TODO: problem, this method is called after a view_to_remove.renderer has 186 | # already changed in view_table.changeRenderer 187 | # this should be refactored, so this method here can call 188 | # view_to_remove.renderer.prepareToDelete() if such a method exists 189 | def removeView(self, view_to_remove: View): 190 | axis_to_remove = self.axes.pop(view_to_remove) 191 | vb_to_remove = self.vbs.pop(view_to_remove) 192 | assert isinstance(vb_to_remove, pg.ViewBox) 193 | assert isinstance(axis_to_remove, pg.AxisItem) 194 | self.layout.removeItem(vb_to_remove) 195 | del vb_to_remove 196 | if axis_to_remove in self.layout.childItems(): 197 | view_to_remove.is_selected() 198 | self.layout.removeItem(axis_to_remove) 199 | if not self.axes: 200 | self.layout.layout.setColumnFixedWidth(0, 201 | self.main_window.axis_width) 202 | self.updateViews() 203 | self.updateWidestAxis() 204 | if not self.vbs: 205 | self.axis_bottom.hide() 206 | self.layout.update() 207 | 208 | def hideView(self, view_to_hide: View): 209 | self.vbs[view_to_hide].setXLink(None) 210 | self.vbs[view_to_hide].hide() 211 | axis = self.axes[view_to_hide] 212 | width = axis.width() 213 | axis.showLabel(show=False) 214 | axis.setStyle(showValues=False) 215 | axis.setWidth(w=width) 216 | self.main_vb.setFixedWidth(self.vbs[view_to_hide].width()) 217 | 218 | def showView(self, view_to_show: View): 219 | self.axes[view_to_show].showLabel(show=True) 220 | if not isinstance(view_to_show.renderer, rendering.Partition): 221 | self.axes[view_to_show].setStyle(showValues=True) 222 | self.vbs[view_to_show].setXLink(self.main_vb) 223 | self.vbs[view_to_show].show() 224 | self.updateViews() 225 | 226 | @Slot(View, name='changeColor') 227 | def changeColor(self, view_object: View): 228 | view_object.renderer.changePen() 229 | 230 | @Slot(name='Align Views') 231 | def alignViews(self): 232 | x_min, x_max = self.selected_view().renderer.vb.viewRange()[0] 233 | for view, vb in self.vbs.items(): 234 | if view.is_selected(): 235 | continue 236 | vb.setXRange(x_min, x_max, padding=0) 237 | self.axis_bottom.setRange(x_min, x_max) 238 | 239 | @Slot(name='updateViews') 240 | def updateViews(self): 241 | if self.selected_view() is None \ 242 | or not self.main_vb.width() \ 243 | or not self.main_vb.height(): 244 | return 245 | track = self.selected_view().track 246 | # termining max zoom 247 | minXRange = self.main_vb.screenGeometry().width() / track.fs 248 | x_min, x_max = self.main_vb.viewRange()[0] 249 | for view, view_box in self.vbs.items(): 250 | view_box.blockSignals(True) 251 | if view_box.geometry() != self.main_vb.sceneBoundingRect(): 252 | view_box.setGeometry(self.main_vb.sceneBoundingRect()) 253 | view_box.setLimits(minXRange=minXRange) # applying max zoom 254 | view_box.setXRange(x_min, x_max, padding=0) 255 | view_box.blockSignals(False) 256 | self.axis_bottom.setRange(x_min, x_max) 257 | 258 | def zoomChanged(self): 259 | if self.main_vb.geometry(): 260 | try: 261 | pixel_width = self.main_vb.viewPixelSize()[0] 262 | self.main_vb.setLimits(xMin=-pixel_width) 263 | for vb in self.vbs.values(): 264 | vb.setLimits(xMin=-pixel_width) 265 | except Exception as e: 266 | logger.exception('Why is this happening?') 267 | # has to do with the viewbox geometry not being rendered 268 | # and i'm asking for the pixel width 269 | # my if condition was wrong (now fixed) 270 | 271 | @Slot(View, name='selectionChanged') 272 | def selectionChanged(self, selected_view: View): 273 | assert selected_view is self.selected_view() 274 | self.blockViewBoxSignals() 275 | old_axis = self.layout.getItem(0, 0) 276 | if old_axis in self.axes.values(): 277 | self.layout.removeItem(old_axis) 278 | self.layout.addItem(self.vbs[self.selected_view()], 279 | row=0, 280 | col=1) 281 | self.layout.addItem(self.axes[self.selected_view()], 282 | row=0, 283 | col=0) 284 | self.unblockViewBoxSignals() 285 | 286 | def updateWidestAxis(self): 287 | self.maxWidthChanged.emit() 288 | 289 | def selected_view(self) -> View: 290 | return self.display_panel.panel.selected_view 291 | 292 | @Slot(float, name='setAxesWidths') 293 | def setAxesWidths(self, width: float): 294 | if not self.axes or width == 0: 295 | return 296 | for axis in self.axes.values(): 297 | if axis.width() != width: 298 | axis.blockSignals(True) 299 | axis.setWidth(w=width) 300 | axis.blockSignals(False) 301 | self.layout.update() 302 | 303 | def blockViewBoxSignals(self): 304 | self.main_vb.blockSignals(True) 305 | for vb in self.vbs.values(): 306 | vb.blockSignals(True) 307 | 308 | def unblockViewBoxSignals(self): 309 | self.main_vb.blockSignals(False) 310 | for vb in self.vbs.values(): 311 | vb.blockSignals(False) 312 | -------------------------------------------------------------------------------- /timeview/gui/plot_objects.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import numpy as np 4 | import pyqtgraph as pg 5 | from qtpy.QtCore import Signal 6 | 7 | logger = logging.getLogger() 8 | logger.setLevel(logging.INFO) 9 | 10 | 11 | class InfiniteLinePlot(pg.PlotItem): 12 | updatePartitionPosition = Signal(int, name='updatePartitionPosition') 13 | updatePartitionValuePosition = Signal(int, float, name='updatePartitionValuePosition') 14 | updatePartitionValue = Signal(int, name='updatePartitionValue') 15 | updatePartitionBoundaries = Signal(int, float, float, name='updatePartitionBoundaries') 16 | delete_segment = Signal(int, name='delete_segment') 17 | reload = Signal(name='reload') 18 | 19 | def __init__(self, **kwargs): 20 | super().__init__() 21 | self.setMenuEnabled(False) 22 | if 'view' in kwargs.keys(): 23 | self.view = kwargs['view'] -------------------------------------------------------------------------------- /timeview/gui/rendering.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABCMeta, abstractmethod 3 | from pathlib import Path 4 | from typing import List, Union, Tuple, Optional, Type, Dict 5 | from math import floor, ceil 6 | from timeit import default_timer as timer 7 | 8 | import numpy as np 9 | import pyqtgraph as pg 10 | from qtpy import QtCore, QtGui, QtWidgets 11 | from qtpy.QtCore import Slot, Signal 12 | 13 | from ..dsp import tracking, dsp, processing 14 | from .plot_objects import InfiniteLinePlot 15 | 16 | logger = logging.getLogger() 17 | logger.setLevel(logging.INFO) 18 | 19 | 20 | class InvalidDataError(Exception): 21 | pass 22 | 23 | 24 | class InvalidParameterError(Exception): 25 | pass 26 | 27 | 28 | class LabelEventFilter(QtCore.QObject): 29 | select_next = Signal(QtWidgets.QGraphicsTextItem) 30 | select_previous = Signal(QtWidgets.QGraphicsTextItem) 31 | 32 | # be careful if changing this code, it's not intuitive, but I saw no other 33 | # way to get the desired behavior 34 | def eventFilter(self, 35 | obj: Union[QtWidgets.QGraphicsTextItem], 36 | event): 37 | if isinstance(obj, QtWidgets.QGraphicsTextItem): 38 | if event.type() == QtCore.QEvent.GraphicsSceneMouseDoubleClick: 39 | obj.setTextInteractionFlags(QtCore.Qt.TextEditable) 40 | obj.setFocus() 41 | event.accept() 42 | 43 | elif event.type() == QtCore.QEvent.KeyPress: 44 | if QtGui.QGuiApplication.keyboardModifiers() == \ 45 | QtCore.Qt.NoModifier: 46 | if event.key() == QtCore.Qt.Key_Return: 47 | obj.clearFocus() 48 | return True 49 | 50 | elif event.key() == QtCore.Qt.Key_Tab: 51 | self.select_next.emit(obj) 52 | return True 53 | 54 | elif event.key() == QtCore.Qt.Key_Backtab: 55 | self.select_previous.emit(obj) 56 | return True 57 | 58 | elif event.type() == QtCore.QEvent.FocusIn: 59 | obj.setTextInteractionFlags(QtCore.Qt.TextEditorInteraction) 60 | event.accept() 61 | 62 | elif event.type() == QtCore.QEvent.FocusOut: 63 | obj.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse) 64 | event.accept() 65 | event.ignore() 66 | return False 67 | event.ignore() 68 | return False 69 | 70 | 71 | class Renderer(metaclass=ABCMeta): # MixIn 72 | accepts = tracking.Track 73 | z_value: int = 0 74 | name = 'metaclass' 75 | 76 | def __init__(self, *args, **parameters): 77 | self.track: Optional[processing.Tracks] = None 78 | self.view = None 79 | self.item: Union[pg.PlotItem, 80 | pg.PlotCurveItem, 81 | pg.ImageItem, 82 | InfiniteLinePlot, 83 | None] = None 84 | self.ax: Optional[pg.AxisItem] = None 85 | self.vb: Optional[pg.ViewBox] = None 86 | self.segments: List[pg.InfiniteLine] = [] 87 | self.names: List[pg.TextItem] = [] 88 | self.filter: Optional[QtCore.QObject] = None 89 | self.pen: Optional[QtGui.QPen] = None 90 | self.plot_area: pg.GraphicsView = None 91 | self.parameters = parameters 92 | if 'y_min' not in self.parameters: 93 | self.parameters['y_min'] = 0 94 | if 'y_max' not in self.parameters: 95 | self.parameters['y_max'] = 1 96 | 97 | def __str__(self) -> str: 98 | return self.name 99 | 100 | def set_track(self, track: accepts): 101 | self.track = track 102 | 103 | def set_view(self, view, **kwargs): 104 | self.view = view 105 | self.set_track(view.track) 106 | self.parameters['y_min'], self.parameters['y_max'] =\ 107 | self.getDefaultYRange() 108 | self.parameters = {**self.parameters, **kwargs} 109 | 110 | def set_parameters(self, parameters: Dict[str, str]) -> None: 111 | old_parameters = self.parameters 112 | if __debug__: 113 | for name, value in parameters.items(): 114 | logging.debug(f'Received parameter {name} of value {value}') 115 | try: 116 | for key, value in parameters.items(): 117 | if isinstance(self.parameters[key], np.ndarray): 118 | self.parameters[key] = np.fromstring(value. 119 | rstrip(')]'). 120 | lstrip('[('), sep=' ') 121 | elif value.isdigit(): 122 | self.parameters[key] = int(value) 123 | elif value.replace('.', '', 1).isdigit(): 124 | self.parameters[key] = float(value) 125 | else: 126 | self.parameters[key] = type(self.parameters[key])(value) 127 | except Exception as e: 128 | raise InvalidParameterError(e) 129 | 130 | remaining_parameters = self.allRendererParameterProcessing(parameters) 131 | self.perRendererParameterProcessing(remaining_parameters) 132 | 133 | def allRendererParameterProcessing(self, 134 | parameters: Dict) -> Dict: 135 | if 'y_min' in parameters or 'y_max' in parameters: 136 | if self.check_y_limits(): 137 | parameters.pop('y_min', None) 138 | parameters.pop('y_max', None) 139 | self.setLimits() 140 | return parameters 141 | 142 | def check_y_limits(self): 143 | y_min = self.parameters['y_min'] 144 | y_max = self.parameters['y_max'] 145 | 146 | if y_min >= y_max: 147 | logger.warning('y-min value set greater or equal to y-max value') 148 | return False 149 | return True 150 | 151 | def get_parameters(self) -> Dict[str, str]: 152 | return {k: str(v) for k, v in self.parameters.items()} 153 | 154 | def strColor(self) -> str: 155 | q_color = QtGui.QColor.fromRgb(self.view.color[0], 156 | self.view.color[1], 157 | self.view.color[2]) 158 | return f'#{pg.colorStr(q_color)[:6]}' 159 | 160 | def setAxisLabel(self): 161 | self.ax.setLabel(self.track.label, color=self.strColor(), units=self.track.unit) 162 | 163 | def configNewAxis(self): 164 | assert isinstance(self.ax, pg.AxisItem) 165 | assert isinstance(self.vb, pg.ViewBox) 166 | self.ax.setZValue(self.z_value) 167 | axis_width = self.plot_area.main_window.axis_width 168 | self.setAxisLabel() 169 | if isinstance(self, Partition): 170 | self.ax.setStyle(showValues=False) 171 | self.ax.linkToView(self.vb) 172 | if self.ax.preferredWidth() <= axis_width: 173 | self.ax.setWidth(w=axis_width) 174 | old_axis = self.plot_area.layout.getItem(0, 0) 175 | if isinstance(old_axis, pg.AxisItem): 176 | if old_axis.width() > self.ax.width(): 177 | axis_width = old_axis.width() 178 | self.ax.setWidth(w=axis_width) 179 | self.plot_area.layout.removeItem(old_axis) 180 | self.ax.update() 181 | self.plot_area.layout.addItem(self.ax, 182 | row=0, 183 | col=0) 184 | self.ax.geometryChanged.connect(self.plot_area.maxWidthChanged) 185 | 186 | def configNewViewBox(self): 187 | assert isinstance(self.vb, pg.ViewBox) 188 | self.setLimits() 189 | self.vb.setZValue(self.z_value) 190 | self.vb.setXLink(self.plot_area.main_vb) 191 | self.plot_area.layout.addItem(self.vb, 192 | row=0, 193 | col=1) 194 | 195 | def render(self, 196 | plot_area) -> Tuple[pg.AxisItem, pg.ViewBox]: 197 | """generates pg.AxisItem and pg.ViewBox""" 198 | self.plot_area = plot_area 199 | self.generateBlankPlotItems() 200 | self.vb.setMouseEnabled(x=True, y=False) 201 | self.vb.setMenuEnabled(False) 202 | return self.ax, self.vb 203 | 204 | @abstractmethod 205 | def reload(self): 206 | """clears current plot items, and reloads the track""" 207 | 208 | @abstractmethod 209 | def perRendererParameterProcessing(self, parameters): 210 | """depending on what the parameters changed call different methods""" 211 | 212 | @abstractmethod 213 | def generateBlankPlotItems(self): 214 | """creates plot items""" 215 | 216 | @abstractmethod 217 | def getDefaultYRange(self) -> Tuple[Union[int, float], Union[int, float]]: 218 | """returns the default y-bounds of this renderer""" 219 | 220 | def changePen(self): 221 | """changes the color/colormap of the plot""" 222 | self.setPen() 223 | self.setAxisLabel() 224 | self.item.setPen(self.pen) 225 | 226 | def setPen(self): 227 | self.pen = pg.mkPen(self.view.color) 228 | 229 | def setLimits(self): 230 | assert isinstance(self.vb, pg.ViewBox) 231 | self.check_y_limits() 232 | self.vb.setYRange(self.parameters['y_min'], 233 | self.parameters['y_max']) 234 | self.vb.setLimits(yMin=self.parameters['y_min'], 235 | yMax=self.parameters['y_max']) 236 | 237 | 238 | def get_renderer_classes(accepts: Optional[tracking.Track] = None) \ 239 | -> List[Type[Renderer]]: 240 | def all_subclasses(c: Type[Renderer]): 241 | return c.__subclasses__() + [a for b in c.__subclasses__() 242 | for a in all_subclasses(b)] 243 | 244 | if accepts is None: 245 | return [obj for obj in all_subclasses(Renderer) 246 | if obj.accepts is not None] 247 | else: 248 | return [obj for obj in all_subclasses(Renderer) 249 | if obj.accepts == accepts] 250 | 251 | 252 | # first renderer will be the default for that track type 253 | # | | | 254 | # v v v 255 | 256 | 257 | class Waveform(Renderer): 258 | name = 'Waveform' 259 | accepts = tracking.Wave 260 | z_value = 10 261 | 262 | def getDefaultYRange(self) -> Tuple[float, float]: 263 | if self.track.min and self.track.max: 264 | return self.track.min, self.track.max 265 | else: 266 | return {np.dtype('int16'): (-32768, 32768), 267 | np.dtype('float'): (-1, 1)}[self.track.value.dtype] 268 | 269 | def reload(self): 270 | # TODO: waveform needs some kind of update scheme 271 | pass 272 | 273 | def perRendererParameterProcessing(self, parameters): 274 | # TODO: look at parameters and modify things accordingly 275 | pass 276 | 277 | def generateBlankPlotItems(self): 278 | self.item = pg.PlotCurveItem() 279 | self.item.setZValue(self.z_value) 280 | self.vb = pg.ViewBox() 281 | self.vb.addItem(self.item, ignoreBounds=True) 282 | self.ax = pg.AxisItem('left') 283 | self.configNewAxis() 284 | self.configNewViewBox() 285 | self.vb.setMouseEnabled(x=True, y=False) 286 | self.vb.sigXRangeChanged.connect(self.generatePlotData, 287 | QtCore.Qt.DirectConnection) 288 | 289 | def generatePlotData(self): 290 | # don't bother computing if there is no screen geometry 291 | if not self.vb.width(): 292 | return 293 | # x_min, x_max = self.plot_area.main_vb.viewRange()[0] 294 | x_min, x_max = self.vb.viewRange()[0] 295 | start = max([0, int(floor(x_min * self.track.fs))]) 296 | assert start >= 0 297 | if start > self.track.duration: 298 | return 299 | stop = min([self.track.duration, int(ceil(x_max * self.track.fs)) + 1]) 300 | ds = int(round((stop - start) / self.vb.screenGeometry().width())) + 1 301 | if ds <= 0: 302 | logger.exception('ds should be > 0') 303 | return 304 | 305 | if ds == 1: 306 | visible = self.track.value[start:stop] 307 | else: 308 | samples = 1 + ((stop - start) // ds) 309 | visible = np.empty(samples * 2, dtype=self.track.value.dtype) 310 | source_pointer = start 311 | target_pointer = 0 312 | 313 | chunk_size = int(round((1e6 // ds) * ds)) 314 | # assert isinstance(source_pointer, int) 315 | # assert isinstance(chunk_size, int) 316 | while source_pointer < stop - 1: 317 | chunk = self.track.value[ 318 | source_pointer:min([stop, source_pointer + chunk_size])] 319 | source_pointer += len(chunk) 320 | chunk =\ 321 | chunk[:(len(chunk) // ds) * ds].reshape(len(chunk) // ds, 322 | ds) 323 | chunk_max = chunk.max(axis=1) 324 | chunk_min = chunk.min(axis=1) 325 | chunk_len = chunk.shape[0] 326 | visible[target_pointer:target_pointer + chunk_len * 2:2] =\ 327 | chunk_min 328 | visible[1 + target_pointer:1 + target_pointer + chunk_len * 2:2] =\ 329 | chunk_max 330 | target_pointer += chunk_len * 2 331 | visible = visible[:target_pointer] 332 | self.item.setData(x=np.linspace(start, 333 | stop, 334 | num=len(visible), 335 | endpoint=True) / self.track.fs, 336 | y=visible, 337 | pen=self.view.color) 338 | 339 | 340 | class Spectrogram(Renderer): 341 | name = 'Spectrogram' 342 | accepts = tracking.Wave 343 | z_value = -100 344 | 345 | def __init__(self, *args, **kwargs): 346 | super().__init__(*args, **kwargs) 347 | self.parameters = {'frame_size': 0.01, 348 | 'normalized': 0, 349 | 'y_min': 0, 350 | 'y_max': 1} 351 | for parameter, value in kwargs.items(): 352 | if parameter in self.parameters.keys(): 353 | self.parameters[parameter] = value 354 | # TODO: fft_size: 'auto' 355 | # TODO: frame_rate: 'auto' 356 | self.vmin = self.vmax = None 357 | 358 | def setAxisLabel(self): 359 | self.ax.setLabel('frequency', color=self.strColor(), units='Hz') 360 | 361 | def reload(self): 362 | # TODO: make some kind of reload method for the spectrogram 363 | pass 364 | 365 | def set_track(self, track: accepts): 366 | logging.info('setting spectrogram track') 367 | super().set_track(track) 368 | #self.set_parameters(self.parameters) # TODO: discuss this 369 | self.compute_initial_levels() 370 | 371 | def compute_initial_levels(self): 372 | if self.parameters['normalized']: 373 | self.vmin = 0 374 | self.vmax = 1 375 | else: 376 | half = self.parameters['frame_size'] * self.track.fs // 2 377 | centers = np.round(np.linspace(half, self.track.duration - half, 1000)).astype(np.int) 378 | X, f = dsp.spectrogram_centered(self.track, 379 | self.parameters['frame_size'], 380 | centers, 381 | NFFT=256, 382 | normalized=self.parameters['normalized']) 383 | self.vmin = np.min(X) 384 | self.vmax = np.max(X) 385 | # need to have serious thinking here 386 | 387 | def set_parameters(self, parameters: Dict[str, str]): 388 | logging.info('setting spectrogram parameters') 389 | Renderer.set_parameters(self, parameters) 390 | self.compute_initial_levels() 391 | self.generatePlotData() 392 | 393 | def perRendererParameterProcessing(self, parameters): 394 | # # TODO: look at parameters and modify things accordingly 395 | # for parameter, value in parameters.items(): 396 | # if parameter == 'frame_size': 397 | # self.generatePlotData() 398 | # if 'y_min' in parameters or 'y_max' in parameters: 399 | # self.setLimits() 400 | self.setLimits() 401 | self.generatePlotData() 402 | 403 | def getDefaultYRange(self) -> Tuple[float, float]: 404 | return 0, self.track.fs / 2 405 | 406 | def generateBlankPlotItems(self): 407 | self.item = pg.ImageItem() 408 | self.item.setZValue(self.z_value) 409 | self.applyColor(self.view.color) 410 | self.vb = pg.ViewBox(parent=self.plot_area.main_vb) 411 | self.vb.addItem(self.item, ignoreBounds=True) 412 | self.ax = pg.AxisItem('left') 413 | self.configNewAxis() 414 | self.configNewViewBox() 415 | self.plot_area.main_vb.sigXRangeChanged.connect(self.generatePlotData) 416 | self.plot_area.main_vb.sigResized.connect(self.generatePlotData) 417 | 418 | def prepareForDeletion(self): 419 | self.plot_area.main_vb.sigXRangeChanged.disconnect(self.generatePlotData) 420 | self.plot_area.main_vb.sigResized.disconnect(self.generatePlotData) 421 | 422 | def generatePlotData(self): 423 | start = timer() 424 | if not self.vb or not self.vb.width(): 425 | return 426 | screen_geometry = self.vb.screenGeometry() 427 | if screen_geometry is None: 428 | return 429 | fs = self.track.fs 430 | t_min, t_max = self.vb.viewRange()[0] 431 | 432 | # determine frame_rate and NFFT automatically 433 | NFFT = 2 ** max(dsp.nextpow2(screen_geometry.height() * 2), int(np.ceil(np.log2(self.parameters['frame_size'] * fs)))) 434 | centers = np.round(np.linspace(t_min, t_max, screen_geometry.width(), 435 | endpoint=True) * fs).astype(np.int) 436 | # this computes regions that are sometimes grossly out of range... 437 | if 0: # enable this to see when it is called 438 | print(f'track: {str(self.track.path).split("/")[-1]}, ' 439 | f'view range: {t_min:{0}.{4}}:{t_max:{0}.{4}},' 440 | f'width: {screen_geometry.width()}') 441 | X, f = dsp.spectrogram_centered(self.track, 442 | self.parameters['frame_size'], 443 | centers, 444 | NFFT=NFFT, 445 | normalized=self.parameters['normalized']) 446 | if X.shape[0]: 447 | # TODO: how about calculating this after setting the render params? 448 | top = np.searchsorted(f, self.parameters['y_max']) 449 | bottom = np.searchsorted(f, self.parameters['y_min']) 450 | self.item.setImage(image=np.fliplr(-X[:, bottom:top]), levels=[-self.vmax, -self.vmin]) 451 | 452 | rect = self.vb.viewRect() 453 | rect.setBottom(f[bottom]) 454 | rect.setTop(f[top-1]) 455 | self.item.setRect(rect) 456 | # print(f'Spectrogram Shape {X.shape}') 457 | # print(f'Screen Geometry: {screen_geometry.width()} x {screen_geometry.height()}') 458 | # print(f'Height: {screen_geometry.height()} \t NFFT: {NFFT}') 459 | # print(f"computation took {timer() - start:{0}.{4}} seconds") 460 | 461 | def changePen(self): 462 | self.applyColor(self.view.color) 463 | self.setAxisLabel() 464 | 465 | def applyColor(self, color): 466 | pos = np.array([1., 0.]) 467 | color = np.array([[0, 0, 0], color], dtype=np.ubyte) 468 | c_map = pg.ColorMap(pos, color) 469 | lut = c_map.getLookupTable(start=0.0, stop=1.0, nPts=256, alpha=True) 470 | self.item.setLookupTable(lut) 471 | 472 | 473 | # TODO: Alex will implement Corellogram, once unified (with plot_objects) 474 | # # and resampled Spectrogram is available 475 | # class Correlogram(Renderer): 476 | # name = 'Correlogram' 477 | # accepts = tracking.Wave 478 | # z_value = -101 479 | # 480 | # def getDefaultYRange(self) -> Tuple[float, float]: 481 | # return 0, self.track.fs / 2 482 | # 483 | # def render(self): 484 | # # will call dsp.correlogram() 485 | # raise NotImplementedError 486 | # 487 | # def changePen(self): 488 | # self.applyColor(self.view.color) 489 | # self.setAxisLabel() 490 | 491 | 492 | class TimeValue(Renderer): 493 | name = 'Time-Value (read-only)' 494 | accepts = tracking.TimeValue 495 | z_value = 11 496 | 497 | def getDefaultYRange(self) -> Tuple[float, float]: 498 | return self.track.min, self.track.max 499 | 500 | def reload(self): 501 | pass 502 | 503 | def perRendererParameterProcessing(self, parameters): 504 | # TODO: look at parameters and modify things accordingly 505 | if 'y_min' in parameters or 'y_max' in parameters: 506 | self.setLimits() 507 | pass 508 | 509 | # TODO: finish me: editing time-only, value-only, 510 | # time+value, insertion, deletion 511 | def generateBlankPlotItems(self): 512 | self.item = pg.PlotCurveItem(self.track.time / self.track.fs, 513 | self.track.value, 514 | pen=self.view.color, connect='finite') 515 | self.ax = pg.AxisItem('left') 516 | self.vb = pg.ViewBox() 517 | self.ax.linkToView(self.vb) 518 | self.configNewAxis() 519 | self.configNewViewBox() 520 | self.vb.addItem(self.item, ignoreBounds=True) 521 | self.vb.setMouseEnabled(x=True, y=False) 522 | 523 | 524 | class Partition(Renderer): 525 | accepts = None # "abstract" rendering class 526 | vertical_placement = 0.1 527 | z_value = 100 528 | 529 | def perRendererParameterProcessing(self, parameters): 530 | if 'y_min' in parameters or 'y_max' in parameters: 531 | self.setLimits() 532 | # TODO: look at parameters and modify things accordingly 533 | pass 534 | 535 | def getDefaultYRange(self) -> Tuple[float, float]: 536 | return 0, 1 537 | 538 | def reload(self): 539 | """method to reload track""" 540 | self.vb.blockSignals(True) 541 | self.clearViewBox() 542 | self.segments = [] 543 | self.names = [] 544 | self.createSegments() 545 | self.vb.blockSignals(False) 546 | 547 | def clearViewBox(self): 548 | for n, item in enumerate(self.vb.addedItems[:]): 549 | try: 550 | self.vb.addedItems.remove(item) 551 | except AttributeError: 552 | try: 553 | self.plot_area.removeItem(item) 554 | except AttributeError: 555 | pass 556 | pass 557 | 558 | for ch in self.vb.childGroup.childItems(): 559 | ch.setParentItem(None) 560 | 561 | def generateBlankPlotItems(self): 562 | kwargs = {'view': self.view} 563 | self.filter = LabelEventFilter() 564 | self.setPen() 565 | self.item = InfiniteLinePlot(**kwargs) 566 | self.item.setZValue(self.z_value) 567 | self.item.disableAutoRange() 568 | self.ax = self.item.getAxis('left') 569 | self.vb = self.item.getViewBox() 570 | self.configNewAxis() 571 | self.configNewViewBox() 572 | self.vb.setMouseEnabled(x=True, y=False) 573 | self.createSegments() 574 | 575 | def createSegments(self): 576 | assert len(self.track.value) == len(self.track.time) - 1 577 | self.createLines() 578 | self.createNames() 579 | self.positionLabels() 580 | # print(f'items added to viewbox {len(self.vb.addedItems)}') 581 | 582 | def createLines(self): 583 | assert isinstance(self.vb, pg.ViewBox) 584 | times = self.track.time / self.track.fs 585 | for time in times: 586 | line = self.genLine(time) 587 | line.sigDragged.connect(self.movePartition) 588 | line.sigPositionChangeFinished.connect(self.updateAdjacentBounds) 589 | self.segments.append(line) 590 | self.vb.addItem(line) 591 | self.segments[0].setMovable(False) 592 | for index, segment in enumerate(self.segments[1:-1], start=1): 593 | self.updateBounds(segment, index) 594 | self.vb.sigXRangeChanged.connect(self.refreshBounds) 595 | 596 | def genName(self, value) -> pg.TextItem: 597 | name = pg.TextItem(str(value), 598 | anchor=(0.5, self.vertical_placement), 599 | color=self.view.color, 600 | border=pg.mkPen(0.4, width=1), 601 | fill=None) 602 | name.textItem.document().contentsChanged.connect(self.nameChanged) 603 | name.textItem.setParent(name) 604 | if isinstance(self, PartitionEdit): 605 | name.textItem.installEventFilter(self.filter) 606 | name.textItem.setTabChangesFocus(True) 607 | name.textItem.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse) 608 | return name 609 | 610 | def createNames(self): 611 | # filter has to be class attribute to prevent garbage collection 612 | self.filter.select_next.connect(self.select_next) 613 | self.filter.select_previous.connect(self.select_previous) 614 | self.names = [self.genName(value) 615 | for value in self.track.value] 616 | for name in self.names: 617 | self.vb.addItem(name) 618 | 619 | def positionLabels(self): 620 | for index, label in enumerate(self.names): 621 | self.calcPartitionNamePlacement(label, 622 | index=index) 623 | 624 | def nameChanged(self): 625 | obj: pg.TextItem = self.item.sender().parent().parent().parentItem() 626 | assert isinstance(obj, pg.TextItem) 627 | index = self.names.index(obj) 628 | self.track.value[index] = obj.textItem.document().toPlainText() 629 | self.item.updatePartitionValue.emit(index) 630 | self.calcPartitionNamePlacement(obj, index) 631 | 632 | def select_next(self, source: QtWidgets.QGraphicsTextItem): 633 | text_item: pg.TextItem = source.parentItem() 634 | index = self.names.index(text_item) 635 | new_index = (index + 1) % len(self.names) 636 | source.clearFocus() 637 | self.focusNew(new_index) 638 | 639 | def focusNew(self, index: int): 640 | self.names[index].textItem.setFocus() 641 | 642 | def select_previous(self, source): 643 | index = self.names.index(source.parentItem()) 644 | source.clearFocus() 645 | self.focusNew(index - 1) 646 | 647 | def calcPartitionNamePlacement(self, 648 | label: pg.TextItem, 649 | index: int, 650 | emit_signal=False): 651 | start_point = self.segments[index].getXPos() 652 | end_point = self.segments[index + 1].getXPos() 653 | mid_point = start_point + ((end_point - start_point) / 2) 654 | label.setPos(mid_point, self.vertical_placement) 655 | label.updateTextPos() 656 | if emit_signal: 657 | self.item.updatePartitionValuePosition.emit(index, mid_point) 658 | 659 | def removeSegment(self, 660 | line: pg.InfiniteLine, 661 | modify_track=True): 662 | index = self.segments.index(line) 663 | # would be silly to remove beginning or end line 664 | if index == 0 or index == len(self.segments) - 1: 665 | return 666 | label = self.names[index] 667 | 668 | self.segments.remove(line) 669 | self.names.remove(label) 670 | 671 | self.vb.removeItem(line) 672 | self.vb.removeItem(label) 673 | 674 | if modify_track: 675 | self.track.delete_merge_left(index) 676 | self.calcPartitionNamePlacement(self.names[index - 1], 677 | index - 1, 678 | emit_signal=False) 679 | self.updateBounds(self.segments[index - 1], index - 1) 680 | self.updateBounds(self.segments[index], index) 681 | if modify_track: 682 | self.item.delete_segment.emit(index) 683 | 684 | line.deleteLater() 685 | label.deleteLater() 686 | 687 | def receivePartitionValuePosition(self, index: int, mid_point: float): 688 | self.names[index].setPos(mid_point, self.vertical_placement) 689 | self.names[index].updateTextPos() 690 | 691 | def receiveRemoveSegment(self, _): 692 | # TODO: on remove segment, we may not have to reload from scratch? 693 | self.reload() 694 | 695 | def receivePartitionPosition(self, index): 696 | self.segments[index].setPos(self.track.time[index] / self.track.fs) 697 | 698 | def receivePartitionBoundaries(self, 699 | index: int, 700 | x_min: float, 701 | x_max: float): 702 | self.segments[index].setBounds((x_min, x_max)) 703 | 704 | def receivePartitionValue(self, index): 705 | self.names[index].textItem.document().contentsChanged.disconnect() 706 | self.names[index].setText(str(self.track.value[index])) 707 | self.names[index].textItem.document().contentsChanged.\ 708 | connect(self.nameChanged) 709 | 710 | def movePartition(self, line: pg.InfiniteLine): 711 | index = self.segments.index(line) 712 | self.track.time[index] = int(round(line.getXPos() * self.track.fs)) 713 | self.calcPartitionNamePlacement(self.names[index - 1], 714 | index=index - 1, 715 | emit_signal=True) 716 | if index < len(self.segments) - 1: 717 | self.calcPartitionNamePlacement(self.names[index], 718 | index=index, 719 | emit_signal=True) 720 | self.item.updatePartitionPosition.emit(index) 721 | 722 | # @Slot(pg.ViewBox, Tuple) #, name='refreshBounds') 723 | def refreshBounds(self, _, xrange: Tuple[float, float]): 724 | # determine the lines in the view 725 | x_min, x_max = xrange 726 | for index, line in enumerate(self.segments[1:-1], start=1): 727 | if line.getXPos() < x_min: 728 | continue 729 | elif x_min <= line.getXPos() <= x_max: 730 | self.updateBounds(line, index) 731 | else: 732 | break 733 | self.positionLabels() 734 | 735 | def updateBounds(self, line: pg.InfiniteLine, index: int): 736 | if index == 0 or index == len(self.segments) - 1: 737 | return 738 | last_partition = self.segments[index - 1].getXPos() 739 | next_partition = self.segments[index + 1].getXPos() 740 | cushion = self.calcCushion() 741 | min_bounds = last_partition + cushion 742 | max_bounds = next_partition - cushion 743 | line.setBounds((min_bounds, max_bounds)) 744 | self.item.updatePartitionBoundaries.emit(index, min_bounds, max_bounds) 745 | 746 | def calcCushion(self) -> float: 747 | if self.vb.width(): 748 | cushion = max([self.vb.viewPixelSize()[0], 1 / self.track.fs]) 749 | else: 750 | cushion = 1 / self.track.fs 751 | return cushion 752 | 753 | def positionChangeFinished(self, line: pg.InfiniteLine): 754 | # called on infiniteLine.sigPositionChangeFinished 755 | index = self.segments.index(line) 756 | self.updateAdjacentBounds(index) 757 | 758 | def updateAdjacentBounds(self, index: int): 759 | self.updateBounds(self.segments[index - 1], index - 1) 760 | if index < len(self.segments) - 1: 761 | self.updateBounds(self.segments[index + 1], index + 1) 762 | 763 | def changePen(self): 764 | """changes the color/colormap of the plot""" 765 | pen = pg.mkPen(self.view.color) 766 | for name in self.names: 767 | name.setColor(self.view.color) 768 | for line in self.segments: 769 | line.setPen(pen) 770 | self.setAxisLabel() 771 | 772 | def genLine(self, time, movable=False): 773 | line = pg.InfiniteLine(time, 774 | angle=90, 775 | pen=self.pen, 776 | movable=movable) 777 | line.parent = self 778 | return line 779 | 780 | 781 | class PartitionRO(Partition): 782 | accepts = tracking.Partition 783 | name = 'Partition (read-only)' 784 | 785 | def createLines(self): 786 | times = self.track.time / self.track.fs 787 | self.segments = [] 788 | for time in times: 789 | line = self.genLine(time, movable=False) 790 | self.segments.append(line) 791 | self.vb.addItem(line) 792 | 793 | def createNames(self): 794 | self.names = [self.genName(value) 795 | for value in self.track.value] 796 | for name in self.names: 797 | name.textItem.setParent(name) 798 | self.vb.addItem(name) 799 | 800 | 801 | class PartitionEdit(Partition): 802 | accepts = tracking.Partition 803 | name = 'Partition (editable)' 804 | 805 | def createLines(self): 806 | self.segments = [] 807 | times = self.track.time / self.track.fs 808 | for time in times: 809 | line = self.genLine(time, movable=True) 810 | line.sigDragged.connect(self.movePartition) 811 | line.sigPositionChangeFinished.connect(self.positionChangeFinished) 812 | self.segments.append(line) 813 | self.vb.addItem(line) 814 | self.segments[0].setMovable(False) 815 | 816 | for index, segment in enumerate(self.segments[1:-1], start=1): 817 | self.updateBounds(segment, index) 818 | 819 | def insertSegment(self, x_pos: float): 820 | # how to check if correct view is selected 821 | new_value = ' ' 822 | time = np.array([x_pos * self.track.fs]).astype(int)[0] 823 | index = np.searchsorted(self.track.time, 824 | np.array([time])).astype(int)[0] 825 | dist_left = time - self.track.time[index - 1] 826 | if index == len(self.track.time): 827 | dist_right = 1 828 | new_name = self.genName(new_value) 829 | self.names.append(new_name) 830 | self.vb.addItem(new_name) 831 | item_appended = True 832 | else: 833 | item_appended = False 834 | dist_right = self.track.time[index] - time 835 | assert dist_left > 0 836 | assert dist_right > 0 837 | 838 | if dist_right > dist_left: 839 | value = self.track.value[index - 1] 840 | self.track.insert(time, value) 841 | self.track.value[index - 1] = new_value 842 | else: 843 | self.track.insert(time, new_value) 844 | self.item.reload.emit() 845 | 846 | if dist_right > dist_left or item_appended: 847 | self.names[index - 1].textItem.setFocus() 848 | else: 849 | self.names[index].textItem.setFocus() 850 | 851 | 852 | class Event(Renderer): 853 | accepts = tracking.Event 854 | name = 'Event' 855 | z_value = 12 856 | 857 | def getDefaultYRange(self) -> Tuple[float, float]: 858 | return 0, 1 859 | 860 | def reload(self): 861 | pass 862 | 863 | def perRendererParameterProcessing(self, parameters): 864 | # TODO: look at parameters and modify things accordingly 865 | pass 866 | 867 | def render(self) -> Tuple[pg.AxisItem, pg.ViewBox]: 868 | raise NotImplementedError 869 | # TODO: implement me: adding, deleting, moving of events 870 | 871 | 872 | def main(): 873 | # example 874 | print(get_renderer_classes()) 875 | track = tracking.Track.read(Path(__file__).parents[2].resolve() / 876 | '/dat/speech.wav') 877 | print([o for o in get_renderer_classes(type(track))]) 878 | # build dictionary of per-track availability of renderers 879 | available_renderers = {t.__name__: 880 | {r.name: r for r in get_renderer_classes(t)} 881 | for t in tracking.get_track_classes()} 882 | print(available_renderers) 883 | print(f"default wave renderer: {next(iter(available_renderers['Wave']))}") 884 | 885 | 886 | if __name__ == '__main__': 887 | main() 888 | -------------------------------------------------------------------------------- /timeview/gui/view_table.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from itertools import cycle 3 | from typing import Tuple, List, Optional 4 | from functools import partial 5 | from math import ceil 6 | 7 | # 3rd party 8 | from qtpy import QtCore, QtGui, QtWidgets 9 | from qtpy.QtCore import Slot, Signal 10 | 11 | from .model import Panel, View 12 | 13 | logger = logging.getLogger() 14 | logger.setLevel(logging.DEBUG) 15 | 16 | # pastel colors, will change to color-blind with an alternate option of bright 17 | colors = [(146, 198, 255), 18 | (151, 240, 170), 19 | (255, 159, 154), 20 | (208, 187, 255), 21 | (255, 254, 163), 22 | (176, 224, 230)] 23 | plot_colors = cycle(colors) 24 | 25 | 26 | class ShowCheckBox(QtWidgets.QWidget): 27 | 28 | def __init__(self): 29 | super().__init__() 30 | self.checkbox = QtWidgets.QCheckBox() 31 | layout = QtWidgets.QHBoxLayout() 32 | layout.addWidget(self.checkbox) 33 | layout.setAlignment(QtCore.Qt.AlignCenter) 34 | self.setLayout(layout) 35 | self.checkbox.setChecked(True) 36 | 37 | 38 | class ViewTable(QtWidgets.QTableWidget): 39 | rendererChanged = Signal(View, name='rendererChanged') 40 | colorChanged = Signal(View, name='colorChanged') 41 | plotViewObj = Signal(View, name='plotViewObj') 42 | tableWidth = Signal(int, name='tableWidth') 43 | colWidths = Signal(list, name='colWidths') 44 | showPlot = Signal(View, int, name='showPlot') 45 | hidePlot = Signal(View, name='hidePlot') 46 | newSelected = Signal(View, name='newSelected') 47 | 48 | def __init__(self, 49 | display_panel, 50 | col_widths: Optional[List[int]]=None): 51 | super().__init__() 52 | self.display_panel = display_panel 53 | self.main_window = self.display_panel.main_window 54 | self.panel: Optional[Panel] = None 55 | self.columns = ('File', 56 | 'Type', 57 | 'Rendering', 58 | 'Show', 59 | 'Color') 60 | self.setColumnCount(len(self.columns)) 61 | self.setHorizontalHeaderLabels(self.columns) 62 | self.setSizePolicy(QtWidgets.QSizePolicy.Fixed, 63 | QtWidgets.QSizePolicy.Expanding) 64 | self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) 65 | self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) 66 | self.verticalHeader().hide() 67 | self.horizontalHeader().setStretchLastSection(False) 68 | self.horizontalHeader().setSectionsClickable(False) 69 | self.horizontalHeader()\ 70 | .setSectionResizeMode(2, QtWidgets.QHeaderView.Fixed) 71 | self.colWidths.connect(self.main_window.determineColumnWidths) 72 | self.hidePlot.connect(display_panel.hideView) 73 | self.showPlot.connect(display_panel.showView) 74 | self.newSelected.connect(display_panel.selectionChanged) 75 | self.itemSelectionChanged.connect(self.evalSelection) 76 | self.rendererChanged.connect(display_panel.rendererChanged) 77 | self.colorChanged.connect(display_panel.changeColor) 78 | self.plotViewObj.connect(display_panel.plotViewObj) 79 | 80 | if col_widths: 81 | self.setColumnWidths(col_widths) 82 | self.setFixedWidth(self.viewportSizeHint().width() + 2) 83 | self.setContentsMargins(0, 0, 0, 0) 84 | 85 | def colNameToIndex(self, name: str) -> int: 86 | return self.columns.index(name) 87 | 88 | def loadPanel(self, panel_obj: Panel): 89 | self.setRowCount(0) 90 | assert isinstance(panel_obj, Panel) 91 | self.panel = panel_obj 92 | for view in panel_obj.views: 93 | self.addView(view) 94 | 95 | def delView(self, view_to_remove): 96 | """Remove the selected views from the panel""" 97 | row_to_remove = self.selectedRow() 98 | assert row_to_remove == self.panel.views.index(view_to_remove) 99 | if row_to_remove is None: 100 | return 101 | self.removeRow(row_to_remove) 102 | self.calcColumnWidths() 103 | 104 | def selectedView(self) -> Optional[View]: 105 | """Returns the currently selected view""" 106 | if not self.selectedIndexes(): 107 | logger.warning('Selected Indexes returned nothing') 108 | self.selectRow(0) 109 | return self.panel.selected_view 110 | 111 | def selectedRow(self) -> int: 112 | if self.selectedIndexes(): 113 | row = self.selectedIndexes()[0].row() 114 | return row 115 | elif self.rowCount() > 0: 116 | logger.error('Rows exist but no row selected, selecting last row as guess') 117 | row = self.panel.views.index(self.panel.selected_view - 1) 118 | self.selectRow(row) 119 | return row 120 | else: 121 | return -1 122 | 123 | @Slot(name='evalSelection') 124 | def evalSelection(self): 125 | if self.rowCount() > 1: 126 | if 0 <= self.selectedRow() < self.rowCount(): 127 | self.selectRow(self.selectedRow(), bypass=True) 128 | else: 129 | if self.panel.selected_view: 130 | logger.error('No selected row, querying model') 131 | row = self.panel.views.index(self.panel.selected_view) 132 | self.selectRow(row, bypass=False) 133 | else: 134 | logger.error('No selected row, no model.Panel.selected_view either') 135 | self.panel.set_selected_view(self.panel.views[self.rowCount() - 1]) 136 | row = self.panel.views.index(self.panel.selected_view) 137 | self.selectRow(row, bypass=False) 138 | 139 | # overloading select row operator so I can emit a signal to plot to change 140 | # and change the selected view property in the model.Model() 141 | def selectRow(self, row: int, bypass=False): 142 | if not bypass: 143 | super().selectRow(row) 144 | previous_selected_view = self.panel.selected_view 145 | new_selected_view = self.panel.views[row] 146 | self.panel.set_selected_view(new_selected_view) 147 | if new_selected_view is not previous_selected_view: 148 | self.newSelected.emit(new_selected_view) 149 | 150 | @Slot(name='changeRenderer') 151 | def changeRenderer(self): 152 | # select the row of the sender 153 | self.selectRow(self.rowFromWidget(self.sender())) 154 | view = self.selectedView() 155 | assert isinstance(view, View) 156 | view.change_renderer(self.sender().currentText()) 157 | self.rendererChanged.emit(view) 158 | 159 | def indexFromWidget(self, widget: QtCore.QObject) -> Tuple[int, int]: 160 | for _ in range(5): # only go 5 layers recursively 161 | if isinstance(widget.parent().parent(), QtWidgets.QTableWidget): 162 | index = self.indexAt(widget.pos()) 163 | return index.row(), index.column() 164 | widget = widget.parent() 165 | else: 166 | logging.error('Could not find appropriate widget to map') 167 | raise IndexError 168 | 169 | def rowFromWidget(self, widget) -> int: 170 | return self.indexFromWidget(widget)[0] 171 | 172 | @Slot(int, name='toggleView') 173 | def toggleView(self, state: int): 174 | self.selectRow(self.rowFromWidget(self.sender())) 175 | if state == 2: 176 | self.selectedView().show = True 177 | self.showPlot.emit(self.selectedView(), self.selectedRow()) 178 | else: 179 | self.selectedView().show = False 180 | self.hidePlot.emit(self.selectedView()) 181 | 182 | def _configureFileLabel(self, view_object: View): 183 | font = QtGui.QFont("Monospace") 184 | font.setStyleHint(font.TypeWriter, strategy=font.PreferDefault) 185 | font.setFixedPitch(True) 186 | text = str(view_object.track.path.name) 187 | desired_length = 32 188 | if len(text) > desired_length: 189 | beginning = text[: ceil(desired_length / 2)] 190 | end = text[(len(text) - ceil(desired_length / 2)) + 1:] 191 | text = beginning + "⋯" + end 192 | assert len(text) <= 32 193 | fileLabel = QtWidgets.QLabel(text) 194 | fileLabel.setMargin(5) 195 | fileLabel.setFont(font) 196 | fileLabel.setAlignment(QtCore.Qt.AlignCenter) 197 | fileLabel.setToolTip(str(view_object.track.path)) 198 | fileLabel.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) 199 | fileLabel.customContextMenuRequested.connect(self.viewPopup) 200 | self.setCellWidget(self.panel.views.index(view_object), 201 | self.colNameToIndex("File"), 202 | fileLabel) 203 | 204 | def _configureTrackItem(self, view_object: View): 205 | text = type(view_object.track).__name__ 206 | trackLabel = QtWidgets.QLabel(text) 207 | trackLabel.setMargin(5) 208 | trackLabel.setAlignment(QtCore.Qt.AlignCenter) 209 | trackLabel.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) 210 | trackLabel.customContextMenuRequested.connect(self.viewPopup) 211 | self.setCellWidget(self.panel.views.index(view_object), 212 | self.colNameToIndex("Type"), 213 | trackLabel) 214 | 215 | def _configureComboBox(self, view_object: View): 216 | render_combo_box = QtWidgets.QComboBox() 217 | render_combo_box.addItems([str(renderer) for renderer 218 | in view_object.track2renderers[ 219 | type(view_object.track).__name__].keys()]) 220 | render_combo_box.setCurrentText(view_object.renderer.name) 221 | render_combo_box.activated['QString'].connect(self.changeRenderer) 222 | self.setCellWidget(self.panel.views.index(view_object), 223 | self.colNameToIndex("Rendering"), 224 | render_combo_box) 225 | 226 | def _configureShowBox(self, view_object: View): 227 | show_check_box = ShowCheckBox() 228 | show_check_box.checkbox.setChecked(view_object.show) 229 | show_check_box.checkbox.stateChanged.connect(self.toggleView) 230 | self.setCellWidget(self.panel.views.index(view_object), 231 | self.colNameToIndex('Show'), 232 | show_check_box) 233 | 234 | def _configureColor(self, view_object: View): 235 | color_button = QtWidgets.QPushButton() 236 | row = self.panel.views.index(view_object) 237 | col = 4 238 | r = view_object.color[0] 239 | g = view_object.color[1] 240 | b = view_object.color[2] 241 | color_button.setStyleSheet(f"background-color: rgb({r}, {g}, {b})") 242 | color_button.clicked.connect(self.changeColor) 243 | self.setCellWidget(row, col, color_button) 244 | 245 | @Slot(name='changeColor') 246 | def changeColor(self): 247 | self.selectRow(self.rowFromWidget(self.sender())) 248 | existing_color = createQColor(self.selectedView().color) 249 | dialog = QtWidgets.QColorDialog() 250 | for index, color in enumerate(colors): 251 | dialog.setCustomColor(index, createQColor(color)) 252 | color = dialog.getColor(existing_color) 253 | if not color.isValid(): 254 | logging.warning(f'Non valid color {color.getRgb()[0:3]}') 255 | return 256 | r, g, b = color.getRgb()[0:3] 257 | self.selectedView().set_color((r, g, b)) 258 | self.sender().setStyleSheet(f"background-color: rgb({r}, {g}, {b})") 259 | self.colorChanged.emit(self.selectedView()) 260 | 261 | def addView(self, view_object: View, setColor=True): 262 | """ 263 | Add the view_object to the existing panel 264 | and display information in the table 265 | """ 266 | if setColor: 267 | view_object.set_color(next(plot_colors)) 268 | pos = self.panel.views.index(view_object) 269 | self.insertRow(pos) 270 | self._configureFileLabel(view_object) 271 | self._configureComboBox(view_object) 272 | self._configureShowBox(view_object) 273 | self._configureTrackItem(view_object) 274 | self._configureColor(view_object) 275 | self.calcColumnWidths() 276 | self.plotViewObj.emit(view_object) 277 | self.selectRow(pos) 278 | 279 | @Slot(name='calcColumnWidths') 280 | def calcColumnWidths(self): 281 | self.resizeColumnsToContents() 282 | self.colWidths.emit([self.columnWidth(col) 283 | for col in range(self.columnCount())]) 284 | 285 | @Slot(list, name='setColumnWidths') 286 | def setColumnWidths(self, widths: List[int]): 287 | for col, width in enumerate(widths): 288 | self.setColumnWidth(col, width) 289 | new_width = self.viewportSizeHint().width() + 2 290 | self.setFixedWidth(new_width) 291 | # self.updateMaxWidth.emit() 292 | 293 | @Slot(QtCore.QPoint, name='viewPopup') 294 | def viewPopup(self, point): 295 | if isinstance(self.sender(), QtWidgets.QHeaderView): 296 | row = self.sender().logicalIndexAt(point) 297 | elif isinstance(self.sender(), QtWidgets.QLabel): 298 | row = self.rowFromWidget(self.sender()) 299 | else: 300 | logging.error(f'do not know how to handle getting index of ', 301 | f'{self.sender()} object') 302 | raise TypeError 303 | view = self.panel.views[row] 304 | menu = QtWidgets.QMenu(self.verticalHeader()) 305 | menu.clear() 306 | move_menu = menu.addMenu('&Move View') 307 | link_menu = menu.addMenu("&Link Track") 308 | copy_menu = menu.addMenu("Copy View") 309 | 310 | linkAction = QtWidgets.QAction('Create Link in this Panel', self) 311 | linkAction.triggered.connect(partial(self.display_panel.linkTrack, 312 | view, 313 | self.main_window.model.panels.index(self.panel))) 314 | link_menu.addAction(linkAction) 315 | link_menu.addSeparator() 316 | 317 | copyAction = QtWidgets.QAction("Duplicate View in this Panel", self) 318 | copyAction.triggered.connect(partial(self.display_panel.copyView, 319 | view, 320 | self.main_window.model.panels.index(self.panel))) 321 | copy_menu.addAction(copyAction) 322 | copy_menu.addSeparator() 323 | 324 | for index, panel in enumerate(self.main_window.model.panels): 325 | if panel is self.panel: 326 | continue 327 | linkAction = QtWidgets.QAction(f'Link to Panel {index + 1}', 328 | self) 329 | linkAction.triggered.connect(partial(self.display_panel.linkTrack, view, index)) 330 | link_menu.addAction(linkAction) 331 | 332 | moveAction = QtWidgets.QAction(f'Move To Panel {index + 1}', 333 | self) 334 | moveAction.triggered.connect(partial(self.display_panel.moveView, view, index)) 335 | move_menu.addAction(moveAction) 336 | 337 | copyAction = QtWidgets.QAction(f'Copy to Panel {index + 1}', 338 | self) 339 | copyAction.triggered.connect(partial(self.display_panel.copyView, view, index)) 340 | copy_menu.addAction(copyAction) 341 | 342 | moveAction = QtWidgets.QAction(f'Move to New Panel', 343 | self) 344 | moveAction.triggered.connect(partial(self.display_panel.moveView, view, -1)) 345 | 346 | linkAction = QtWidgets.QAction(f'Link to New Panel', 347 | self) 348 | linkAction.triggered.connect(partial(self.display_panel.linkTrack, view, -1)) 349 | 350 | copyAction = QtWidgets.QAction(f'Copy to New Panel', 351 | self) 352 | copyAction.triggered.connect(partial(self.display_panel.copyView, view, -1)) 353 | 354 | move_menu.addSeparator() 355 | link_menu.addSeparator() 356 | copy_menu.addSeparator() 357 | move_menu.addAction(moveAction) 358 | link_menu.addAction(linkAction) 359 | copy_menu.addAction(copyAction) 360 | menu.popup(QtGui.QCursor.pos()) 361 | 362 | 363 | def createQColor(rgb: Tuple[int, int, int]) -> QtGui.QColor: 364 | return QtGui.QColor.fromRgb(rgb[0], rgb[1], rgb[2]) 365 | -------------------------------------------------------------------------------- /timeview/manager/__init__.py: -------------------------------------------------------------------------------- 1 | from . import dataset_manager, dataset_manager_model -------------------------------------------------------------------------------- /timeview/manager/dataset_manager.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Manager""" 5 | 6 | 7 | # TODO: integrate with timeview GUI 8 | 9 | # STL 10 | import sys 11 | import logging 12 | from pathlib import Path 13 | 14 | # 3rd party 15 | from sqlalchemy import create_engine 16 | from sqlalchemy.exc import IntegrityError, StatementError 17 | from qtpy import QtCore, uic 18 | from qtpy.QtWidgets import QMainWindow, QFileDialog, QInputDialog, QMessageBox 19 | import qtawesome as qta 20 | 21 | # local 22 | from .dataset_manager_model import Dataset, File, Model 23 | 24 | 25 | logger = logging.getLogger(__name__) 26 | # ch = logging.StreamHandler() 27 | # ch.setLevel(logging.DEBUG) 28 | # logger.addHandler(ch) 29 | 30 | ENGINE_PATH = 'sqlite:///' + str(Path(__file__).with_name('dataset.db')) 31 | 32 | class TableModel(QtCore.QAbstractTableModel): 33 | # see also for alchemical model gist 34 | # https://gist.github.com/harvimt/4699169 35 | 36 | def __init__(self, model, parent): 37 | QtCore.QAbstractTableModel.__init__(self, parent) 38 | self.model = model 39 | 40 | # need to store parent QWidget, self.parent() is private / has different functionality 41 | self.widget = parent 42 | self.qry = [] # list of query result rows 43 | self.refresh() 44 | 45 | # def index(self, row, column, parent=QtCore.QModelIndex()): 46 | # return self.createIndex(row, column) 47 | 48 | # def parent(self, child): 49 | # return 0 50 | 51 | def rowCount(self, _parent=None): 52 | return len(self.qry) 53 | 54 | def columnCount(self, _parent=None): 55 | return len(self.tbl.columns) 56 | 57 | def headerData(self, col, orientation, role): 58 | if role == QtCore.Qt.DisplayRole: 59 | if orientation == QtCore.Qt.Horizontal: 60 | return self.tbl.columns[col] 61 | elif orientation == QtCore.Qt.Vertical: 62 | # self.qry[section].id 63 | # nobody wants to know ids presumably 64 | return col + 1 65 | 66 | def data(self, q_index, role=QtCore.Qt.DisplayRole): 67 | if q_index.isValid() and role == QtCore.Qt.DisplayRole: 68 | return self.qry[q_index.row()][q_index.column()] 69 | 70 | def flags(self, q_index): 71 | defaults = QtCore.QAbstractTableModel.flags(self, q_index) 72 | return defaults | QtCore.Qt.ItemIsEditable 73 | 74 | def setData(self, q_index, value, _role): 75 | # print('setdata') 76 | try: 77 | self.qry[q_index.row()][q_index.column()] = value 78 | self.model.session.commit() 79 | except (IntegrityError, StatementError) as e: 80 | self.model.session.rollback() 81 | self.widget.statusBar().showMessage(str(e)) 82 | return False 83 | else: 84 | # TODO: correctly used? 85 | self.dataChanged.emit(q_index, q_index) 86 | self.widget.statusBar().showMessage('Updated.') 87 | return True 88 | 89 | def change_layout(self): 90 | self.layoutAboutToBeChanged.emit() 91 | self.refresh() 92 | self.layoutChanged.emit() 93 | 94 | 95 | class TableModelDataset(TableModel): 96 | def __init__(self, *args, **kwargs): 97 | self.tbl = Dataset 98 | TableModel.__init__(self, *args, **kwargs) 99 | 100 | def refresh(self): 101 | self.qry = self.model.get_dataset() 102 | 103 | 104 | class TableModelFile(TableModel): 105 | def __init__(self, *args, **kwargs): 106 | self.dataset_id = None 107 | self.tbl = File 108 | TableModel.__init__(self, *args, **kwargs) 109 | 110 | def flags(self, q_index): 111 | defaults = QtCore.QAbstractTableModel.flags(self, q_index) 112 | return defaults 113 | 114 | def refresh(self): 115 | self.qry = self.model.get_file(None, dataset_id=self.dataset_id) 116 | 117 | 118 | 119 | 120 | class ManagerWindow(QMainWindow): 121 | def __init__(self, title, parent=None): 122 | super(ManagerWindow, self).__init__(parent) 123 | # TODO: for performance reasons, perhaps should precompile using pyuic when UI is near finalized 124 | uic.loadUi(Path(__file__).with_name("main.ui"), self) 125 | 126 | self.viewer = parent 127 | # self.setCentralWidget(self.viewer) 128 | 129 | # model 130 | self.model = Model(create_engine(ENGINE_PATH, echo=False)) 131 | self.tableModelDataset = TableModelDataset(self.model, self) 132 | self.tableModelFile = TableModelFile(self.model, self) 133 | self.tableViewDataset.setModel(self.tableModelDataset) 134 | self.tableViewFile.setModel(self.tableModelFile) 135 | 136 | # GUI 137 | # update file query 138 | self.tableViewDataset.clicked.connect(self.clicked_dataset) 139 | self.tableViewFile.doubleClicked.connect(self.double_clicked_file) 140 | 141 | #self.installEventFilter(self) 142 | 143 | # Icons 144 | self.addDatasetButton.setIcon(qta.icon('fa.plus')) 145 | self.delDatasetButton.setIcon(qta.icon('fa.minus')) 146 | self.addFileButton.setIcon(qta.icon('fa.plus')) 147 | self.delFileButton.setIcon(qta.icon('fa.minus')) 148 | 149 | # first selection 150 | self.tableViewDataset.selectRow(0) 151 | try: 152 | self.tableModelFile.dataset_id = int(self.tableModelDataset.qry[0].id) 153 | except IndexError: 154 | pass 155 | else: 156 | self.tableModelFile.change_layout() 157 | 158 | self.addDatasetButton.clicked.connect(self.add_dataset) 159 | self.delDatasetButton.clicked.connect(self.del_dataset) 160 | self.addFileButton. clicked.connect(self.add_file) 161 | self.delFileButton. clicked.connect(self.del_file) 162 | 163 | # Status bar 164 | # TODO: timed statusBar (goes empty after a while) 165 | self.statusBar().showMessage("Ready.") 166 | 167 | # Window Title 168 | self.setWindowTitle(title) 169 | 170 | def keyPressEvent(self, event): 171 | if event.type() == QtCore.QEvent.KeyPress: 172 | key = event.key() 173 | if key == QtCore.Qt.Key_Return: 174 | rows = [index.row() for index in self.tableViewFile.selectedIndexes()] 175 | file_names = [self.tableModelFile.qry[row].path for row in rows] 176 | for file_name in file_names: 177 | self.viewer.application.add_view_from_file(Path(file_name)) 178 | 179 | def clicked_dataset(self, q_index): # refresh table of files 180 | self.tableModelFile.dataset_id = int(self.tableModelDataset.qry[q_index.row()].id) 181 | self.tableModelFile.change_layout() 182 | 183 | def double_clicked_file(self, q_index): 184 | # print('dblclick') 185 | row = q_index.row() 186 | # col = q_index.column() 187 | # if File.columns[col] == 'path': 188 | file = self.tableModelFile.qry[row].path 189 | self.viewer.application.add_view_from_file(Path(file)) 190 | # self.parent().application.add_Panel 191 | # 192 | # # example setup 193 | # wav_file = Path(__file__).resolve().parents[0] / 'dat' / 'speech.wav' 194 | # wav_obj = Track.read(wav_file) 195 | # 196 | # app = TimeView() 197 | # # panel 0 exists already at this point 198 | # app.add_view(0, wav_obj) 199 | # app.add_view(0, lab_obj) 200 | # app.add_panel() 201 | # app.add_view(1, wav_obj, renderer='Spectrogram') # linked 202 | # app.add_view(1, lab_obj) # linked 203 | # app.start() 204 | ########## 205 | #self.viewer.show() 206 | 207 | def add_dataset(self, _e): 208 | while True: 209 | name, ok = QInputDialog.getText(self, 210 | 'New Dataset', 211 | 'Enter the name of the new dataset:') 212 | if ok: 213 | try: 214 | # TODO: how to set defaults? 215 | self.model.add_dataset(Dataset(name=name)) #parameter=0)) 216 | except IntegrityError: 217 | self.model.session.rollback() 218 | QMessageBox.information(self, 219 | "Cannot proceed", 220 | "This dataset name already exists, \ 221 | please select a different one (or cancel).", 222 | defaultButton=QMessageBox.Ok) 223 | else: 224 | self.tableViewDataset.model().change_layout() 225 | self.statusBar().showMessage('Added dataset.') 226 | # self.tableViewDataset.selectRow(len(self.model.dataset) - 1) 227 | break 228 | else: # cancel 229 | self.statusBar().showMessage('Cancelled.') 230 | return # exit loop 231 | if len(self.model.get_dataset()) == 1: # first entry after being empty 232 | self.tableViewDataset.selectRow(0) 233 | 234 | def del_dataset(self, _e): 235 | sm = self.tableViewDataset.selectionModel() 236 | if sm.hasSelection(): 237 | q_index = sm.selectedRows() 238 | if len(q_index): 239 | if QMessageBox.question(self, 240 | "Are you sure?", 241 | "You are about to delete a dataset. \ 242 | This will also delete the list of files \ 243 | associated with this dataset.", 244 | buttons=QMessageBox.Ok | QMessageBox.Cancel, 245 | defaultButton=QMessageBox.Cancel) == QMessageBox.Ok: 246 | dataset_id = self.tableViewDataset.model().qry[q_index[0].row()].id 247 | self.model.del_dataset(dataset_id) 248 | self.tableViewDataset.model().change_layout() 249 | self.tableViewFile. model().change_layout() 250 | # because files associated with that dataset are deleted also 251 | self.statusBar().showMessage('Deleted dataset.') 252 | else: 253 | self.statusBar().showMessage('Cancelled.') 254 | 255 | def add_file(self, _e): 256 | sm = self.tableViewDataset.selectionModel() 257 | if sm.hasSelection(): 258 | q_index = sm.selectedRows() 259 | if len(q_index): 260 | dataset_qry = self.tableViewDataset.model().qry 261 | # dataset_id = dataset_qry[q_index[0].row()].id 262 | while True: 263 | paths = QFileDialog.getOpenFileNames(self, 264 | "Select one or more files", 265 | '', 266 | "All Files (*)")[0] 267 | if len(paths): 268 | dataset_id = dataset_qry[q_index[0].row()].id 269 | files = [File(path=path) for path in paths] 270 | try: 271 | self.model.add_files(dataset_id, files) 272 | except IntegrityError as e: # this should not be happening 273 | self.model.session.rollback() 274 | QMessageBox.information(self, 275 | "Integrity Error", 276 | e, 277 | defaultButton=QMessageBox.Ok) 278 | else: 279 | self.tableViewFile.model().change_layout() 280 | self.statusBar().showMessage('Added file(s).') 281 | break 282 | else: # cancel 283 | self.statusBar().showMessage('Cancelled.') 284 | return # exit loop 285 | 286 | def del_file(self, _e): 287 | sm = self.tableViewFile.selectionModel() 288 | if sm.hasSelection(): 289 | q_index = sm.selectedRows() 290 | if len(q_index): 291 | [self.model.del_file(self.tableViewFile.model().qry[qi.row()].id) for qi in q_index] 292 | self.tableViewFile.model().change_layout() 293 | self.statusBar().showMessage('Deleted file(s).') 294 | # else: # cancel 295 | # self.statusBar().showMessage('No file(s) selected.') 296 | -------------------------------------------------------------------------------- /timeview/manager/dataset_manager_model.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | import unittest 3 | 4 | from sqlalchemy import Column, Integer, String, Float, ForeignKey 5 | from sqlalchemy.ext.declarative import declarative_base, declared_attr 6 | from sqlalchemy.orm import relationship, sessionmaker 7 | 8 | Base = declarative_base() 9 | 10 | 11 | class Mixin(object): 12 | @declared_attr 13 | def __tablename__(cls): # table name is lowercase class name 14 | return cls.__name__.lower() 15 | 16 | id = Column(Integer, primary_key=True, autoincrement=True) 17 | 18 | def __len__(self): 19 | return len(self.columns) 20 | 21 | def __getitem__(self, index): 22 | return getattr(self, self.columns[index]) 23 | 24 | def __setitem__(self, index, value): 25 | setattr(self, self.columns[index], value) 26 | 27 | def __repr__(self): 28 | s = 'id={}: '.format(self.id) 29 | s += ' '.join(['{}={}'.format(self.columns[i], self[i]) for i in range(len(self))]) 30 | return s 31 | 32 | 33 | class Dataset(Base, Mixin): 34 | name = Column(String, unique=True, nullable=False) 35 | # parameter = Column(Float, nullable=False) 36 | file = relationship('File', back_populates='dataset', cascade='all, delete, delete-orphan') # cascade configuration 37 | columns = ['name'] 38 | 39 | 40 | class File(Base, Mixin): 41 | path = Column(String, unique=False, nullable=False) # a file can belong to more than one dataset 42 | dataset_id = Column(Integer, ForeignKey('dataset.id')) 43 | # many-to-one 44 | dataset = relationship('Dataset', back_populates='file') 45 | columns = ['path'] 46 | 47 | 48 | class Model(object): 49 | def __init__(self, engine): 50 | Base.metadata.create_all(engine) 51 | session = sessionmaker(bind=engine) 52 | self.session = session() 53 | # discard any stale state from an unclean shutdown 54 | self.session.rollback() 55 | 56 | def add_dataset(self, dataset: Dataset): 57 | self.session.add(dataset) 58 | self.session.commit() 59 | 60 | def add_files(self, dataset_id, files: List[File]): 61 | d = self.session.query(Dataset).get(int(dataset_id)) 62 | if d is not None: 63 | d.file.extend(files) 64 | self.session.add(d) 65 | self.session.commit() 66 | else: 67 | raise KeyError('dataset_id=%i does not exist' % dataset_id) 68 | 69 | def del_dataset(self, dataset_id): 70 | # TODO: int()? 71 | d = self.session.query(Dataset).get(int(dataset_id)) 72 | if d is not None: 73 | self.session.delete(d) # cascading delete 74 | self.session.commit() 75 | else: 76 | raise KeyError(f'dataset_id={dataset_id} does not exist') 77 | 78 | def del_file(self, file_id): 79 | # TODO: int()? 80 | f = self.session.query(File).get(int(file_id)) 81 | if f is not None: 82 | self.session.delete(f) 83 | self.session.commit() 84 | else: 85 | raise KeyError('file_id=%i does not exist' % file_id) 86 | 87 | def get_dataset(self, dataset_id=None) -> Dataset: 88 | """ 89 | returns links to the database objects, 90 | allowing read, and write access for updating 91 | - this needs model.session.commit() afterwards 92 | """ 93 | q = self.session.query(Dataset) 94 | if dataset_id is None: 95 | return q.all() 96 | else: 97 | return q.filter(Dataset.id == dataset_id).first() 98 | 99 | def get_file(self, file_id=None, dataset_id=None) -> File: 100 | q = self.session.query(File) 101 | if dataset_id is None: 102 | if file_id is None: 103 | return q.all() 104 | else: 105 | return q.filter(File.id == file_id).first() 106 | else: 107 | assert file_id is None # disregarding file_id value 108 | return q.filter(File.dataset_id == dataset_id).all() 109 | 110 | 111 | class TestModel(unittest.TestCase): 112 | def setUp(self): 113 | from sqlalchemy import create_engine 114 | engine = create_engine('sqlite://', 115 | echo=False) # memory-based db 116 | self.model = Model(engine) 117 | 118 | def test_morebase(self): 119 | d = Dataset(name='one', parameter=0) 120 | f = File(path='file_path') 121 | s = str(d) 122 | s = str(f) 123 | 124 | def test_allthethings(self): 125 | from sqlalchemy.exc import IntegrityError, StatementError 126 | # sqlalchemy ORM 127 | model = self.model 128 | # start empty 129 | self.assertTrue(len(model.get_dataset()) == 0) 130 | self.assertTrue(len(model.get_file()) == 0) 131 | with self.assertRaises(KeyError): 132 | model.add_files(0, [File(path='')]) # can't add a file for a dataset that doesn't exist 133 | # add dataset 134 | with self.assertRaises(IntegrityError): 135 | model.add_dataset(Dataset(name=None, parameter=0)) # not nullable 136 | model.session.rollback() 137 | d = Dataset(name='one', parameter=0) 138 | self.assertTrue(d.id == None) 139 | model.add_dataset(d) 140 | self.assertTrue(d.id == 1) 141 | with self.assertRaises(IntegrityError): 142 | model.add_dataset(Dataset(name='one', parameter=0)) # must be unique 143 | model.session.rollback() 144 | model.add_dataset(Dataset(name='two', parameter=0)) 145 | self.assertTrue(len(model.get_dataset()) == 2) 146 | # add files 147 | with self.assertRaises(IntegrityError): 148 | model.add_files(1, [File(path=None)]) # not nullable 149 | model.session.rollback() 150 | model.add_files(1, [File(path='1'), File(path='2')]) 151 | model.add_files(2, [File(path='3'), File(path='4')]) 152 | with self.assertRaises(IntegrityError): 153 | model.add_files(1, [File(path='4')]) # files must be unique 154 | model.session.rollback() 155 | with self.assertRaises(KeyError): 156 | model.add_files(3, [File(path='')]) # can't add a file for a dataset that doesn't exist 157 | self.assertTrue(len(model.get_file()) == 4) 158 | self.assertTrue(len(model.get_file(dataset_id=1)) == 2) 159 | self.assertTrue(len(model.get_file(dataset_id=2)) == 2) 160 | self.assertTrue(len(model.get_file(dataset_id=3)) == 0) 161 | # update via index 162 | f = model.get_file(3) 163 | f[0] = 'update' 164 | model.session.commit() 165 | f = model.get_file(3) 166 | self.assertTrue(f.path == 'update') 167 | # update errors 168 | d = model.get_dataset(1) 169 | d[0] = 'two' 170 | with self.assertRaises(IntegrityError): # not unique 171 | model.session.commit() 172 | model.session.rollback() 173 | d[1] = 'hi' 174 | with self.assertRaises(StatementError): # can't assign string to int 175 | model.session.commit() 176 | model.session.rollback() 177 | # delete file 178 | model.del_file(4) 179 | with self.assertRaises(KeyError): 180 | model.del_file(4) 181 | self.assertTrue(len(model.get_file()) == 3) 182 | # delete dataset 183 | model.del_dataset(1) # this cascade deletes associated files 184 | self.assertTrue(len(model.get_dataset()) == 1) 185 | self.assertTrue(len(model.get_file()) == 1) 186 | with self.assertRaises(KeyError): 187 | model.del_dataset(1) 188 | # print(model.get_dataset()) 189 | # print(model.get_file()) 190 | -------------------------------------------------------------------------------- /timeview/manager/main.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MainWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 800 10 | 600 11 | 12 | 13 | 14 | MainWindow 15 | 16 | 17 | 18 | true 19 | 20 | 21 | 22 | 23 | 24 | Datasets (press return to rename) 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | QAbstractItemView::SingleSelection 34 | 35 | 36 | QAbstractItemView::SelectRows 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | Qt::Vertical 46 | 47 | 48 | 49 | 20 50 | 40 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | true 59 | 60 | 61 | 62 | 63 | 64 | false 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | Qt::Horizontal 83 | 84 | 85 | 86 | 87 | 88 | 89 | Files (select one or multiple files, then press return to load) 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | QAbstractItemView::SelectRows 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | Qt::Vertical 108 | 109 | 110 | 111 | 20 112 | 40 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 | &Select All 141 | 142 | 143 | 144 | 145 | &New 146 | 147 | 148 | 149 | 150 | &Open 151 | 152 | 153 | 154 | 155 | &Segmentation 156 | 157 | 158 | 159 | 160 | &Tracking 161 | 162 | 163 | 164 | 165 | &Clustering 166 | 167 | 168 | 169 | 170 | C&lassification 171 | 172 | 173 | 174 | 175 | &Events 176 | 177 | 178 | 179 | 180 | 181 | 182 | --------------------------------------------------------------------------------