mirror of
https://github.com/iluvcapra/wavinfo.git
synced 2026-01-01 09:20:40 +00:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e259e9d6c | ||
|
|
c20b17e82c | ||
|
|
5315575e35 | ||
|
|
52613b78f3 | ||
|
|
0c24c2d986 | ||
|
|
2a86d7824d | ||
|
|
c2be0e77c5 | ||
|
|
9b8b964e74 | ||
|
|
488c0f2aa9 | ||
|
|
da51b22c59 | ||
|
|
a4ffe7dd6b | ||
|
|
0ec5425cd8 | ||
|
|
8e965f53e5 | ||
|
|
9e16f6ab1f | ||
|
|
477c71830e | ||
|
|
ed8b5f167e | ||
|
|
97c25ab61f | ||
|
|
910b3854c7 | ||
|
|
f12bb0eea4 | ||
|
|
c88599f4fd | ||
|
|
68ccb09f53 | ||
|
|
b7d9f6758d | ||
|
|
e3adde7498 | ||
|
|
96885dfb4e | ||
|
|
126f1ea7c3 | ||
|
|
0bb664357e | ||
|
|
9e2c60caf8 | ||
|
|
82d73b0316 | ||
|
|
0612e62a7b | ||
|
|
8fd509482c | ||
|
|
a9dbfdf5ec | ||
|
|
413826b18f | ||
|
|
71201adee4 | ||
|
|
d88117878c | ||
|
|
b20c5dd1bd | ||
|
|
6c9fb38482 | ||
|
|
f5c0700e47 | ||
|
|
3c101badac | ||
|
|
50962aefcc | ||
|
|
f52b2a195a | ||
|
|
4586d19a5f | ||
|
|
3e897d030c | ||
|
|
9f943aeb61 | ||
|
|
a9c3600ad2 | ||
|
|
27d9a2f005 | ||
|
|
0c5c0a2088 |
17
.travis.yml
17
.travis.yml
@@ -1,11 +1,20 @@
|
|||||||
|
dist: xenial
|
||||||
language: python
|
language: python
|
||||||
python:
|
python:
|
||||||
- "3.6"
|
- "3.6"
|
||||||
- "3.5"
|
- "3.5"
|
||||||
- "3.4"
|
|
||||||
script:
|
script:
|
||||||
- "python3 setup.py test"
|
- "python setup.py test"
|
||||||
|
- "py.test tests/ -v --cov wavinfo --cov-report term-missing"
|
||||||
before_install:
|
before_install:
|
||||||
- sudo apt-get install -y ffmpeg
|
- "sudo apt-get update"
|
||||||
|
- "sudo add-apt-repository universe"
|
||||||
|
- "sudo apt-get install -y ffmpeg"
|
||||||
|
- "pip install coverage"
|
||||||
|
# - "pip install coverage==4.4"
|
||||||
|
- "pip install pytest-cov==2.5.0"
|
||||||
|
- "pip install python-coveralls"
|
||||||
install:
|
install:
|
||||||
- "pip3 install setuptools"
|
- "pip install setuptools"
|
||||||
|
after_success:
|
||||||
|
- coveralls
|
||||||
|
|||||||
57
README.md
57
README.md
@@ -1,3 +1,5 @@
|
|||||||
|
[](https://travis-ci.com/iluvcapra/wavinfo)
|
||||||
|
[](https://coveralls.io/github/iluvcapra/wavinfo?branch=master)
|
||||||
[](https://wavinfo.readthedocs.io/en/latest/?badge=latest)   [](https://pypi.org/project/wavinfo/) 
|
[](https://wavinfo.readthedocs.io/en/latest/?badge=latest)   [](https://pypi.org/project/wavinfo/) 
|
||||||
|
|
||||||
|
|
||||||
@@ -22,8 +24,6 @@ In progress:
|
|||||||
* iXML `STEINBERG` sound library attributes.
|
* iXML `STEINBERG` sound library attributes.
|
||||||
* Pro Tools __embedded regions__.
|
* Pro Tools __embedded regions__.
|
||||||
|
|
||||||
This module is presently under construction and not sutiable for production at this time.
|
|
||||||
|
|
||||||
[ebu]:https://tech.ebu.ch/docs/tech/tech3285.pdf
|
[ebu]:https://tech.ebu.ch/docs/tech/tech3285.pdf
|
||||||
[smpte_330m2011]:http://standards.smpte.org/content/978-1-61482-678-1/st-330-2011/SEC1.abstract
|
[smpte_330m2011]:http://standards.smpte.org/content/978-1-61482-678-1/st-330-2011/SEC1.abstract
|
||||||
[ixml]:http://www.ixml.info
|
[ixml]:http://www.ixml.info
|
||||||
@@ -32,10 +32,8 @@ This module is presently under construction and not sutiable for production at t
|
|||||||
|
|
||||||
## Demonstration
|
## Demonstration
|
||||||
|
|
||||||
|
|
||||||
The entry point for wavinfo is the WavInfoReader class.
|
The entry point for wavinfo is the WavInfoReader class.
|
||||||
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from wavinfo import WavInfoReader
|
from wavinfo import WavInfoReader
|
||||||
|
|
||||||
@@ -46,10 +44,8 @@ info = WavInfoReader(path)
|
|||||||
|
|
||||||
### Basic WAV Data
|
### Basic WAV Data
|
||||||
|
|
||||||
|
|
||||||
The length of the file in frames (interleaved samples) and bytes is available, as is the contents of the format chunk.
|
The length of the file in frames (interleaved samples) and bytes is available, as is the contents of the format chunk.
|
||||||
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
(info.data.frame_count, info.data.byte_count)
|
(info.data.frame_count, info.data.byte_count)
|
||||||
>>> (240239, 1441434)
|
>>> (240239, 1441434)
|
||||||
@@ -57,9 +53,27 @@ The length of the file in frames (interleaved samples) and bytes is available, a
|
|||||||
>>> (48000, 2, 6, 24)
|
>>> (48000, 2, 6, 24)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Broadcast WAV Extension
|
### Broadcast WAV Extension
|
||||||
|
|
||||||
|
A WAV file produced to Broadcast-WAV specifications will have the broadcast metadata extension,
|
||||||
|
which includes a 256-character free text descrption, creating entity identifier (usually the
|
||||||
|
recording application or equipment), the date and time of recording and a time reference for
|
||||||
|
timecode synchronization.
|
||||||
|
|
||||||
|
The `coding_history` is designed to contain a record of every conversion performed on the audio
|
||||||
|
file.
|
||||||
|
|
||||||
|
In this example (from a Sound Devices 702T) the bext metadata contains scene/take slating
|
||||||
|
information in the `description`. Here also the `originator_ref` is a serial number conforming
|
||||||
|
to EBU Rec 99.
|
||||||
|
|
||||||
|
If the bext metadata conforms to EBU 3285 v1, it will contain the WAV's 32 or 64 byte SMPTE
|
||||||
|
330M UMID. The 32-byte version of the UMID is usually just a random number, while the 64-byte
|
||||||
|
UMID will also have information on the recording date and time, recording equipment and entity,
|
||||||
|
and geolocation data.
|
||||||
|
|
||||||
|
If the bext metadata conforms to EBU 3285 v2, it will hold precomputed program loudness values
|
||||||
|
as described by EBU Rec 128.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
print(info.bext.description)
|
print(info.bext.description)
|
||||||
@@ -94,8 +108,17 @@ print(info.bext.coding_history)
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
## iXML Production Recorder Metadata
|
### iXML Production Recorder Metadata
|
||||||
|
|
||||||
|
iXML allows an XML document to be embedded in a WAV file.
|
||||||
|
|
||||||
|
The iXML website recommends a schema for recorder information but
|
||||||
|
there is no official DTD and vendors mostly do their own thing, apart from
|
||||||
|
hitting a few key xpaths. iXML is used by most location/production recorders
|
||||||
|
to save slating information, timecode and sync points in a reliable way.
|
||||||
|
|
||||||
|
iXML is also used to link "families" of WAV files together, so WAV files
|
||||||
|
recorded simultaneously or contiguously can be related by a receiving client.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
print("iXML Project:", info.ixml.project)
|
print("iXML Project:", info.ixml.project)
|
||||||
@@ -112,6 +135,22 @@ print("iXML File Family UID:", info.ixml.family_uid)
|
|||||||
iXML Tape: 18Y12M31
|
iXML Tape: 18Y12M31
|
||||||
iXML File Family Name: None
|
iXML File Family Name: None
|
||||||
iXML File Family UID: USSDVGR1112089007124001008206300
|
iXML File Family UID: USSDVGR1112089007124001008206300
|
||||||
|
|
||||||
|
|
||||||
|
### INFO Metadata
|
||||||
|
|
||||||
|
INFO Metadata is a standard method for saving tagged text data in a WAV or AVI
|
||||||
|
file. INFO fields are often read by the file explorer and host OS, and used in
|
||||||
|
music library software.
|
||||||
|
|
||||||
|
```python
|
||||||
|
bullet_path = '../tests/test_files/BULLET Impact Plastic LCD TV Screen Shatter Debris 2x.wav'
|
||||||
|
|
||||||
|
bullet = WavInfoReader(bullet_path)
|
||||||
|
```
|
||||||
|
|
||||||
|
print("INFO Artist:", bullet.info.artist)
|
||||||
|
print("INFO Copyright:", bullet.info.copyright)
|
||||||
|
print("INFO Comment:", bullet.info.comment)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
19
docs/Makefile
Normal file
19
docs/Makefile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Minimal makefile for Sphinx documentation
|
||||||
|
#
|
||||||
|
|
||||||
|
# You can set these variables from the command line.
|
||||||
|
SPHINXOPTS =
|
||||||
|
SPHINXBUILD = sphinx-build
|
||||||
|
SOURCEDIR = .
|
||||||
|
BUILDDIR = _build
|
||||||
|
|
||||||
|
# Put it first so that "make" without argument is like "make help".
|
||||||
|
help:
|
||||||
|
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||||
|
|
||||||
|
.PHONY: help Makefile
|
||||||
|
|
||||||
|
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||||
|
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||||
|
%: Makefile
|
||||||
|
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||||
184
docs/conf.py
Normal file
184
docs/conf.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Configuration file for the Sphinx documentation builder.
|
||||||
|
#
|
||||||
|
# This file does only contain a selection of the most common options. For a
|
||||||
|
# full list see the documentation:
|
||||||
|
# http://www.sphinx-doc.org/en/master/config
|
||||||
|
|
||||||
|
# -- Path setup --------------------------------------------------------------
|
||||||
|
|
||||||
|
# If extensions (or modules to document with autodoc) are in another directory,
|
||||||
|
# add these directories to sys.path here. If the directory is relative to the
|
||||||
|
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||||
|
#
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, os.path.abspath('..'))
|
||||||
|
|
||||||
|
|
||||||
|
# -- Project information -----------------------------------------------------
|
||||||
|
|
||||||
|
project = u'wavinfo'
|
||||||
|
copyright = u'2019, Jamie Hardt'
|
||||||
|
author = u'Jamie Hardt'
|
||||||
|
|
||||||
|
# The short X.Y version
|
||||||
|
version = u''
|
||||||
|
# The full version, including alpha/beta/rc tags
|
||||||
|
release = u'v1.0'
|
||||||
|
|
||||||
|
|
||||||
|
# -- General configuration ---------------------------------------------------
|
||||||
|
|
||||||
|
# If your documentation needs a minimal Sphinx version, state it here.
|
||||||
|
#
|
||||||
|
# needs_sphinx = '1.0'
|
||||||
|
|
||||||
|
# Add any Sphinx extension module names here, as strings. They can be
|
||||||
|
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||||
|
# ones.
|
||||||
|
extensions = [
|
||||||
|
'sphinx.ext.autodoc',
|
||||||
|
'sphinx.ext.todo',
|
||||||
|
'sphinx.ext.coverage',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
|
templates_path = ['_templates']
|
||||||
|
|
||||||
|
# The suffix(es) of source filenames.
|
||||||
|
# You can specify multiple suffix as a list of string:
|
||||||
|
#
|
||||||
|
# source_suffix = ['.rst', '.md']
|
||||||
|
source_suffix = '.rst'
|
||||||
|
|
||||||
|
# The master toctree document.
|
||||||
|
master_doc = 'index'
|
||||||
|
|
||||||
|
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||||
|
# for a list of supported languages.
|
||||||
|
#
|
||||||
|
# This is also used if you do content translation via gettext catalogs.
|
||||||
|
# Usually you set "language" from the command line for these cases.
|
||||||
|
language = None
|
||||||
|
|
||||||
|
# List of patterns, relative to source directory, that match files and
|
||||||
|
# directories to ignore when looking for source files.
|
||||||
|
# This pattern also affects html_static_path and html_extra_path.
|
||||||
|
exclude_patterns = [u'_build', 'Thumbs.db', '.DS_Store']
|
||||||
|
|
||||||
|
# The name of the Pygments (syntax highlighting) style to use.
|
||||||
|
pygments_style = None
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for HTML output -------------------------------------------------
|
||||||
|
|
||||||
|
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||||
|
# a list of builtin themes.
|
||||||
|
#
|
||||||
|
html_theme = 'sphinx_rtd_theme'
|
||||||
|
|
||||||
|
# Theme options are theme-specific and customize the look and feel of a theme
|
||||||
|
# further. For a list of options available for each theme, see the
|
||||||
|
# documentation.
|
||||||
|
#
|
||||||
|
# html_theme_options = {}
|
||||||
|
|
||||||
|
# Add any paths that contain custom static files (such as style sheets) here,
|
||||||
|
# relative to this directory. They are copied after the builtin static files,
|
||||||
|
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||||
|
html_static_path = ['_static']
|
||||||
|
|
||||||
|
# Custom sidebar templates, must be a dictionary that maps document names
|
||||||
|
# to template names.
|
||||||
|
#
|
||||||
|
# The default sidebars (for documents that don't match any pattern) are
|
||||||
|
# defined by theme itself. Builtin themes are using these templates by
|
||||||
|
# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
|
||||||
|
# 'searchbox.html']``.
|
||||||
|
#
|
||||||
|
# html_sidebars = {}
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for HTMLHelp output ---------------------------------------------
|
||||||
|
|
||||||
|
# Output file base name for HTML help builder.
|
||||||
|
htmlhelp_basename = 'wavinfodoc'
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for LaTeX output ------------------------------------------------
|
||||||
|
|
||||||
|
latex_elements = {
|
||||||
|
# The paper size ('letterpaper' or 'a4paper').
|
||||||
|
#
|
||||||
|
# 'papersize': 'letterpaper',
|
||||||
|
|
||||||
|
# The font size ('10pt', '11pt' or '12pt').
|
||||||
|
#
|
||||||
|
# 'pointsize': '10pt',
|
||||||
|
|
||||||
|
# Additional stuff for the LaTeX preamble.
|
||||||
|
#
|
||||||
|
# 'preamble': '',
|
||||||
|
|
||||||
|
# Latex figure (float) alignment
|
||||||
|
#
|
||||||
|
# 'figure_align': 'htbp',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Grouping the document tree into LaTeX files. List of tuples
|
||||||
|
# (source start file, target name, title,
|
||||||
|
# author, documentclass [howto, manual, or own class]).
|
||||||
|
latex_documents = [
|
||||||
|
(master_doc, 'wavinfo.tex', u'wavinfo Documentation',
|
||||||
|
u'Jamie Hardt', 'manual'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for manual page output ------------------------------------------
|
||||||
|
|
||||||
|
# One entry per manual page. List of tuples
|
||||||
|
# (source start file, name, description, authors, manual section).
|
||||||
|
man_pages = [
|
||||||
|
(master_doc, 'wavinfo', u'wavinfo Documentation',
|
||||||
|
[author], 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for Texinfo output ----------------------------------------------
|
||||||
|
|
||||||
|
# Grouping the document tree into Texinfo files. List of tuples
|
||||||
|
# (source start file, target name, title, author,
|
||||||
|
# dir menu entry, description, category)
|
||||||
|
texinfo_documents = [
|
||||||
|
(master_doc, 'wavinfo', u'wavinfo Documentation',
|
||||||
|
author, 'wavinfo', 'One line description of project.',
|
||||||
|
'Miscellaneous'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for Epub output -------------------------------------------------
|
||||||
|
|
||||||
|
# Bibliographic Dublin Core info.
|
||||||
|
epub_title = project
|
||||||
|
|
||||||
|
# The unique identifier of the text. This can be a ISBN number
|
||||||
|
# or the project homepage.
|
||||||
|
#
|
||||||
|
# epub_identifier = ''
|
||||||
|
|
||||||
|
# A unique identification for the text.
|
||||||
|
#
|
||||||
|
# epub_uid = ''
|
||||||
|
|
||||||
|
# A list of files that should not be packed into the epub file.
|
||||||
|
epub_exclude_files = ['search.html']
|
||||||
|
|
||||||
|
|
||||||
|
# -- Extension configuration -------------------------------------------------
|
||||||
|
|
||||||
|
# -- Options for todo extension ----------------------------------------------
|
||||||
|
|
||||||
|
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
||||||
|
todo_include_todos = True
|
||||||
32
docs/index.rst
Normal file
32
docs/index.rst
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
.. wavinfo documentation master file, created by
|
||||||
|
sphinx-quickstart on Thu Jan 3 17:09:28 2019.
|
||||||
|
You can adapt this file completely to your liking, but it should at least
|
||||||
|
contain the root `toctree` directive.
|
||||||
|
|
||||||
|
Welcome to wavinfo's documentation!
|
||||||
|
===================================
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
:caption: Contents:
|
||||||
|
|
||||||
|
.. module:: wavinfo
|
||||||
|
|
||||||
|
.. autoclass:: WavInfoReader
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. automethod:: __init__
|
||||||
|
|
||||||
|
|
||||||
|
.. autoclass:: wavinfo.wave_reader.WavAudioFormat
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. autoclass:: wavinfo.wave_reader.WavDataDescriptor
|
||||||
|
:members:
|
||||||
|
|
||||||
|
Indices and tables
|
||||||
|
==================
|
||||||
|
|
||||||
|
* :ref:`genindex`
|
||||||
|
* :ref:`modindex`
|
||||||
|
* :ref:`search`
|
||||||
35
docs/make.bat
Normal file
35
docs/make.bat
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
@ECHO OFF
|
||||||
|
|
||||||
|
pushd %~dp0
|
||||||
|
|
||||||
|
REM Command file for Sphinx documentation
|
||||||
|
|
||||||
|
if "%SPHINXBUILD%" == "" (
|
||||||
|
set SPHINXBUILD=sphinx-build
|
||||||
|
)
|
||||||
|
set SOURCEDIR=.
|
||||||
|
set BUILDDIR=_build
|
||||||
|
|
||||||
|
if "%1" == "" goto help
|
||||||
|
|
||||||
|
%SPHINXBUILD% >NUL 2>NUL
|
||||||
|
if errorlevel 9009 (
|
||||||
|
echo.
|
||||||
|
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||||
|
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||||
|
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||||
|
echo.may add the Sphinx directory to PATH.
|
||||||
|
echo.
|
||||||
|
echo.If you don't have Sphinx installed, grab it from
|
||||||
|
echo.http://sphinx-doc.org/
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:help
|
||||||
|
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
|
||||||
|
|
||||||
|
:end
|
||||||
|
popd
|
||||||
@@ -11,13 +11,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 2,
|
"execution_count": 1,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
"from wavinfo import WavInfoReader\n",
|
"from wavinfo import WavInfoReader\n",
|
||||||
"\n",
|
"\n",
|
||||||
"path = '../tests/test_files/A101_1.WAV'\n",
|
"path = '../tests/test_files/sounddevices/A101_1.WAV'\n",
|
||||||
"\n",
|
"\n",
|
||||||
"info = WavInfoReader(path)"
|
"info = WavInfoReader(path)"
|
||||||
]
|
]
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 4,
|
"execution_count": 2,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{
|
{
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"(240239, 1441434)"
|
"(240239, 1441434)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"execution_count": 4,
|
"execution_count": 2,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"output_type": "execute_result"
|
"output_type": "execute_result"
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 7,
|
"execution_count": 3,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{
|
{
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"(48000, 2, 6, 24)"
|
"(48000, 2, 6, 24)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"execution_count": 7,
|
"execution_count": 3,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"output_type": "execute_result"
|
"output_type": "execute_result"
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 13,
|
"execution_count": 4,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{
|
{
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 14,
|
"execution_count": 5,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{
|
{
|
||||||
@@ -142,9 +142,7 @@
|
|||||||
"iXML Take: 1\n",
|
"iXML Take: 1\n",
|
||||||
"iXML Tape: 18Y12M31\n",
|
"iXML Tape: 18Y12M31\n",
|
||||||
"iXML File Family Name: None\n",
|
"iXML File Family Name: None\n",
|
||||||
"iXML File Family UID: USSDVGR1112089007124001008206300\n",
|
"iXML File Family UID: USSDVGR1112089007124001008206300\n"
|
||||||
"A=PCM,F=48000,W=24,M=stereo,R=48000,T=2 Ch\r\n",
|
|
||||||
"\n"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -157,6 +155,13 @@
|
|||||||
"print(\"iXML File Family UID:\", info.ixml.family_uid)"
|
"print(\"iXML File Family UID:\", info.ixml.family_uid)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
|
|||||||
5
setup.py
5
setup.py
@@ -4,18 +4,17 @@ with open("README.md", "r") as fh:
|
|||||||
long_description = fh.read()
|
long_description = fh.read()
|
||||||
|
|
||||||
setup(name='wavinfo',
|
setup(name='wavinfo',
|
||||||
version='0.3',
|
version='1.1',
|
||||||
author='Jamie Hardt',
|
author='Jamie Hardt',
|
||||||
author_email='jamiehardt@me.com',
|
author_email='jamiehardt@me.com',
|
||||||
description='WAVE sound file metadata parser.',
|
description='WAVE sound file metadata parser.',
|
||||||
long_description_content_type="text/markdown",
|
long_description_content_type="text/markdown",
|
||||||
long_description=long_description,
|
long_description=long_description,
|
||||||
url='https://github.com/iluvcapra/wavinfo',
|
url='https://github.com/iluvcapra/wavinfo',
|
||||||
classifiers=['Development Status :: 4 - Beta',
|
classifiers=['Development Status :: 5 - Production/Stable',
|
||||||
'License :: OSI Approved :: MIT License',
|
'License :: OSI Approved :: MIT License',
|
||||||
'Topic :: Multimedia',
|
'Topic :: Multimedia',
|
||||||
'Topic :: Multimedia :: Sound/Audio',
|
'Topic :: Multimedia :: Sound/Audio',
|
||||||
"Programming Language :: Python :: 3.4",
|
|
||||||
"Programming Language :: Python :: 3.5",
|
"Programming Language :: Python :: 3.5",
|
||||||
"Programming Language :: Python :: 3.6"],
|
"Programming Language :: Python :: 3.6"],
|
||||||
packages=['wavinfo'])
|
packages=['wavinfo'])
|
||||||
|
|||||||
Binary file not shown.
BIN
tests/test_files/sound_grinder_pro/new_camera bumb 1.wav
Normal file
BIN
tests/test_files/sound_grinder_pro/new_camera bumb 1.wav
Normal file
Binary file not shown.
@@ -7,7 +7,7 @@ from unittest import TestCase
|
|||||||
|
|
||||||
import wavinfo
|
import wavinfo
|
||||||
|
|
||||||
FFPROBE='/usr/local/bin/ffprobe'
|
FFPROBE='ffprobe'
|
||||||
|
|
||||||
|
|
||||||
def ffprobe(path):
|
def ffprobe(path):
|
||||||
@@ -17,7 +17,8 @@ def ffprobe(path):
|
|||||||
process = subprocess.run(arguments, stdin=None, stdout=PIPE, stderr=PIPE)
|
process = subprocess.run(arguments, stdin=None, stdout=PIPE, stderr=PIPE)
|
||||||
|
|
||||||
if process.returncode == 0:
|
if process.returncode == 0:
|
||||||
return json.loads(process.stdout)
|
output_str = process.stdout.decode('utf-8')
|
||||||
|
return json.loads(output_str)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -65,23 +66,23 @@ class TestWaveInfo(TestCase):
|
|||||||
for wav_file in self.all_files():
|
for wav_file in self.all_files():
|
||||||
info = wavinfo.WavInfoReader(wav_file)
|
info = wavinfo.WavInfoReader(wav_file)
|
||||||
ffprobe_info = ffprobe(wav_file)
|
ffprobe_info = ffprobe(wav_file)
|
||||||
|
if info.bext:
|
||||||
|
self.assertEqual( info.bext.description, ffprobe_info['format']['tags']['comment'] )
|
||||||
|
self.assertEqual( info.bext.originator, ffprobe_info['format']['tags']['encoded_by'] )
|
||||||
|
if 'originator_reference' in ffprobe_info['format']['tags']:
|
||||||
|
self.assertEqual( info.bext.originator_ref, ffprobe_info['format']['tags']['originator_reference'] )
|
||||||
|
else:
|
||||||
|
self.assertEqual( info.bext.originator_ref, '')
|
||||||
|
|
||||||
self.assertEqual( info.bext.description, ffprobe_info['format']['tags']['comment'] )
|
# these don't always reflect the bext info
|
||||||
self.assertEqual( info.bext.originator, ffprobe_info['format']['tags']['encoded_by'] )
|
# self.assertEqual( info.bext.originator_date, ffprobe_info['format']['tags']['date'] )
|
||||||
if 'originator_reference' in ffprobe_info['format']['tags']:
|
# self.assertEqual( info.bext.originator_time, ffprobe_info['format']['tags']['creation_time'] )
|
||||||
self.assertEqual( info.bext.originator_ref, ffprobe_info['format']['tags']['originator_reference'] )
|
self.assertEqual( info.bext.time_reference, int(ffprobe_info['format']['tags']['time_reference']) )
|
||||||
else:
|
|
||||||
self.assertEqual( info.bext.originator_ref, '')
|
|
||||||
|
|
||||||
# these don't always reflect the bext info
|
if 'coding_history' in ffprobe_info['format']['tags']:
|
||||||
# self.assertEqual( info.bext.originator_date, ffprobe_info['format']['tags']['date'] )
|
self.assertEqual( info.bext.coding_history, ffprobe_info['format']['tags']['coding_history'] )
|
||||||
# self.assertEqual( info.bext.originator_time, ffprobe_info['format']['tags']['creation_time'] )
|
else:
|
||||||
self.assertEqual( info.bext.time_reference, int(ffprobe_info['format']['tags']['time_reference']) )
|
self.assertEqual( info.bext.coding_history, '' )
|
||||||
|
|
||||||
if 'coding_history' in ffprobe_info['format']['tags']:
|
|
||||||
self.assertEqual( info.bext.coding_history, ffprobe_info['format']['tags']['coding_history'] )
|
|
||||||
else:
|
|
||||||
self.assertEqual( info.bext.coding_history, '' )
|
|
||||||
|
|
||||||
def test_ixml(self):
|
def test_ixml(self):
|
||||||
expected = {'A101_4.WAV': {'project' : 'BMH', 'scene': 'A101', 'take': '4',
|
expected = {'A101_4.WAV': {'project' : 'BMH', 'scene': 'A101', 'take': '4',
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# :module:`wavinfo` provides methods to probe a WAV file for
|
||||||
|
# various kinds of production metadata.
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
from .wave_reader import WavInfoReader
|
from .wave_reader import WavInfoReader
|
||||||
|
|
||||||
__version__ = 0.3
|
__version__ = '1.1'
|
||||||
__author__ = 'Jamie Hardt'
|
__author__ = 'Jamie Hardt'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
import struct
|
import struct
|
||||||
|
import pdb
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
class ListChunkDescriptor(namedtuple('ListChunkDescriptor' , 'signature children')):
|
class ListChunkDescriptor(namedtuple('ListChunkDescriptor' , 'signature children')):
|
||||||
@@ -30,11 +30,16 @@ def parse_list_chunk(stream, length):
|
|||||||
|
|
||||||
children = []
|
children = []
|
||||||
while (stream.tell() - start) < length:
|
while (stream.tell() - start) < length:
|
||||||
children.append(parse_chunk(stream))
|
child_chunk = parse_chunk(stream)
|
||||||
|
if child_chunk:
|
||||||
|
children.append(child_chunk)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
return ListChunkDescriptor(signature=signature, children=children)
|
return ListChunkDescriptor(signature=signature, children=children)
|
||||||
|
|
||||||
def parse_chunk(stream):
|
def parse_chunk(stream):
|
||||||
|
#breakpoint()
|
||||||
ident = stream.read(4)
|
ident = stream.read(4)
|
||||||
if len(ident) != 4:
|
if len(ident) != 4:
|
||||||
return
|
return
|
||||||
@@ -11,19 +11,19 @@ class WavBextReader:
|
|||||||
# lowtimeref U32
|
# lowtimeref U32
|
||||||
# hightimeref U32
|
# hightimeref U32
|
||||||
# version U16
|
# version U16
|
||||||
|
#
|
||||||
|
# V1 field
|
||||||
# umid[64]
|
# umid[64]
|
||||||
#
|
#
|
||||||
# EBU 3285 fields
|
# V2 fields
|
||||||
# loudnessvalue S16 (in LUFS*100)
|
# loudnessvalue S16 (in LUFS*100)
|
||||||
# loudnessrange S16 (in LUFS*100)
|
# loudnessrange S16 (in LUFS*100)
|
||||||
# maxtruepeak S16 (in dbTB*100)
|
# maxtruepeak S16 (in dbTB*100)
|
||||||
# maxmomentaryloudness S16 (LUFS*100)
|
# maxmomentaryloudness S16 (LUFS*100)
|
||||||
# maxshorttermloudness S16 (LUFS*100)
|
# maxshorttermloudness S16 (LUFS*100)
|
||||||
|
#
|
||||||
# reserved[180]
|
# reserved[180]
|
||||||
# codinghistory []
|
# codinghistory []
|
||||||
if bext_data is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
packstring = "<256s"+ "32s" + "32s" + "10s" + "8s" + "QH" + "64s" + "hhhhh" + "180s"
|
packstring = "<256s"+ "32s" + "32s" + "10s" + "8s" + "QH" + "64s" + "hhhhh" + "180s"
|
||||||
|
|
||||||
rest_starts = struct.calcsize(packstring)
|
rest_starts = struct.calcsize(packstring)
|
||||||
@@ -39,33 +39,30 @@ class WavBextReader:
|
|||||||
decoded = trimmed.decode(encoding)
|
decoded = trimmed.decode(encoding)
|
||||||
return decoded
|
return decoded
|
||||||
|
|
||||||
bext_version = unpacked[6]
|
self.description = sanatize_bytes(unpacked[0])
|
||||||
if bext_version > 0:
|
self.originator = sanatize_bytes(unpacked[1])
|
||||||
self.umid = unpacked[6]
|
self.originator_ref = sanatize_bytes(unpacked[2])
|
||||||
else:
|
self.originator_date = sanatize_bytes(unpacked[3])
|
||||||
self.umid = None
|
self.originator_time = sanatize_bytes(unpacked[4])
|
||||||
|
self.time_reference = unpacked[5]
|
||||||
|
self.version = unpacked[6]
|
||||||
|
self.umid = None
|
||||||
|
self.loudness_value = None
|
||||||
|
self.loudness_range = None
|
||||||
|
self.max_true_peak = None
|
||||||
|
self.max_momentary_loudness = None
|
||||||
|
self.max_shortterm_loudness = None
|
||||||
|
self.coding_history = sanatize_bytes(bext_data[rest_starts:])
|
||||||
|
|
||||||
if bext_version > 1:
|
if self.version > 0:
|
||||||
self.loudness_value = unpacked[8] / 100.0,
|
self.umid = unpacked[7]
|
||||||
|
|
||||||
|
if self.version > 1:
|
||||||
|
self.loudness_value = unpacked[8] / 100.0
|
||||||
self.loudness_range = unpacked[9] / 100.0
|
self.loudness_range = unpacked[9] / 100.0
|
||||||
self.max_true_peak = unpacked[10] / 100.0
|
self.max_true_peak = unpacked[10] / 100.0
|
||||||
self.max_momentary_loudness = unpacked[11] / 100.0
|
self.max_momentary_loudness = unpacked[11] / 100.0
|
||||||
self.max_shortterm_loudness = unpacked[12] / 100.0
|
self.max_shortterm_loudness = unpacked[12] / 100.0
|
||||||
else:
|
|
||||||
self.loudness_value = None
|
|
||||||
self.loudness_range = None
|
|
||||||
self.max_true_peak = None
|
|
||||||
self.max_momentary_loudness = None
|
|
||||||
self.max_shortterm_loudness = None
|
|
||||||
|
|
||||||
self.description = sanatize_bytes(unpacked[0])
|
|
||||||
self.originator = sanatize_bytes(unpacked[1])
|
|
||||||
self.originator_ref = sanatize_bytes(unpacked[2])
|
|
||||||
self.originator_date = sanatize_bytes(unpacked[3])
|
|
||||||
self.originator_time = sanatize_bytes(unpacked[4])
|
|
||||||
self.time_reference = unpacked[5]
|
|
||||||
self.version = unpacked[6]
|
|
||||||
self.coding_history = sanatize_bytes(bext_data[rest_starts:])
|
|
||||||
|
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
from .wave_parser import parse_chunk, ListChunkDescriptor
|
from .riff_parser import parse_chunk, ListChunkDescriptor
|
||||||
|
|
||||||
class WavInfoChunkReader:
|
class WavInfoChunkReader:
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
import io
|
||||||
|
|
||||||
class WavIXMLFormat:
|
class WavIXMLFormat:
|
||||||
"""
|
"""
|
||||||
@@ -6,17 +7,18 @@ class WavIXMLFormat:
|
|||||||
"""
|
"""
|
||||||
def __init__(self, xml):
|
def __init__(self, xml):
|
||||||
self.source = xml
|
self.source = xml
|
||||||
self.parsed = ET.fromstring(xml)
|
xmlBytes = io.BytesIO(xml)
|
||||||
|
self.parsed = ET.parse(xmlBytes)
|
||||||
|
|
||||||
def _get_text_value(self, xpath):
|
def _get_text_value(self, xpath):
|
||||||
e = self.parsed.find("./" + xpath)
|
e = self.parsed.find("./" + xpath)
|
||||||
if e is not None:
|
if e is not None:
|
||||||
return e.text
|
return e.text
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def project(self):
|
def project(self):
|
||||||
return self._get_text_value("PROJECT")
|
return self._get_text_value("PROJECT")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def scene(self):
|
def scene(self):
|
||||||
return self._get_text_value("SCENE")
|
return self._get_text_value("SCENE")
|
||||||
|
|||||||
@@ -1,52 +1,56 @@
|
|||||||
|
#-*- coding: utf-8 -*-
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
from .wave_parser import parse_chunk, ChunkDescriptor, ListChunkDescriptor
|
from .riff_parser import parse_chunk, ChunkDescriptor, ListChunkDescriptor
|
||||||
from .wave_ixml_reader import WavIXMLFormat
|
from .wave_ixml_reader import WavIXMLFormat
|
||||||
from .wave_bext_reader import WavBextReader
|
from .wave_bext_reader import WavBextReader
|
||||||
from .wave_info_reader import WavInfoChunkReader
|
from .wave_info_reader import WavInfoChunkReader
|
||||||
|
|
||||||
|
#: Calculated statistics about the audio data.
|
||||||
WavDataDescriptor = namedtuple('WavDataDescriptor','byte_count frame_count')
|
WavDataDescriptor = namedtuple('WavDataDescriptor','byte_count frame_count')
|
||||||
|
|
||||||
WavInfoFormat = namedtuple("WavInfoFormat",'audio_format channel_count sample_rate byte_rate block_align bits_per_sample')
|
#: The format of the audio samples.
|
||||||
|
WavAudioFormat = namedtuple('WavAudioFormat','audio_format channel_count sample_rate byte_rate block_align bits_per_sample')
|
||||||
WavBextFormat = namedtuple("WavBextFormat",'description originator originator_ref ' +
|
|
||||||
'originator_date originator_time time_reference version umid ' +
|
|
||||||
'loudness_value loudness_range max_true_peak max_momentary_loudness max_shortterm_loudness ' +
|
|
||||||
'coding_history')
|
|
||||||
|
|
||||||
|
|
||||||
class WavInfoReader():
|
class WavInfoReader():
|
||||||
"""
|
"""
|
||||||
format : WAV format
|
Parse a WAV audio file for metadata.
|
||||||
bext : The Broadcast-WAV extension as definied by EBU Tech 3285 v2 (2011)
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, path, info_encoding='latin_1', bext_encoding='ascii'):
|
def __init__(self, path, info_encoding='latin_1', bext_encoding='ascii'):
|
||||||
"""
|
"""
|
||||||
Parse a WAV audio file for metadata.
|
Create a new reader object.
|
||||||
|
|
||||||
* `path`: A filesystem path to the wav file you wish to probe.
|
:param path: A filesystem path to the wav file you wish to probe.
|
||||||
|
|
||||||
* `info_encoding`: The text encoding of the INFO metadata fields.
|
:param info_encoding: The text encoding of the INFO metadata fields.
|
||||||
`latin_1`/Win CP1252 has always been a pretty good guess for this.
|
latin_1/Win CP1252 has always been a pretty good guess for this.
|
||||||
|
|
||||||
* `bext_encoding`: The text encoding to use when decoding the string
|
:param bext_encoding: The text encoding to use when decoding the string
|
||||||
fields of the Broadcast-WAV extension. Per EBU 3285 this is ASCII
|
fields of the Broadcast-WAV extension. Per EBU 3285 this is ASCII
|
||||||
but this parameter is available to you if you encounter a werido.
|
but this parameter is available to you if you encounter a werido.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with open(path, 'rb') as f:
|
with open(path, 'rb') as f:
|
||||||
chunks = parse_chunk(f)
|
chunks = parse_chunk(f)
|
||||||
|
|
||||||
self.main_list = chunks.children
|
self.main_list = chunks.children
|
||||||
f.seek(0)
|
f.seek(0)
|
||||||
|
|
||||||
|
#: :class:`wavinfo.wave_reader.WavAudioFormat`
|
||||||
self.fmt = self._get_format(f)
|
self.fmt = self._get_format(f)
|
||||||
|
|
||||||
|
#: :class:`wavinfo.wave_bext_reader.WavBextReader` with Broadcast-WAV metadata
|
||||||
self.bext = self._get_bext(f, encoding=bext_encoding)
|
self.bext = self._get_bext(f, encoding=bext_encoding)
|
||||||
|
|
||||||
|
#: :class:`wavinfo.wave_ixml_reader.WavIXMLFormat` with iXML metadata
|
||||||
self.ixml = self._get_ixml(f)
|
self.ixml = self._get_ixml(f)
|
||||||
|
|
||||||
|
#: :class:`wavinfo.wave_info_reader.WavInfoChunkReader` with RIFF INFO metadata
|
||||||
self.info = self._get_info(f, encoding=info_encoding)
|
self.info = self._get_info(f, encoding=info_encoding)
|
||||||
self.data = self._describe_data(f)
|
self.data = self._describe_data(f)
|
||||||
|
|
||||||
@@ -94,7 +98,7 @@ class WavInfoReader():
|
|||||||
#0xFFFE WAVE_FORMAT_EXTENSIBLE Determined by SubFormat
|
#0xFFFE WAVE_FORMAT_EXTENSIBLE Determined by SubFormat
|
||||||
|
|
||||||
#https://sno.phy.queensu.ca/~phil/exiftool/TagNames/RIFF.html
|
#https://sno.phy.queensu.ca/~phil/exiftool/TagNames/RIFF.html
|
||||||
return WavInfoFormat(audio_format = unpacked[0],
|
return WavAudioFormat(audio_format = unpacked[0],
|
||||||
channel_count = unpacked[1],
|
channel_count = unpacked[1],
|
||||||
sample_rate = unpacked[2],
|
sample_rate = unpacked[2],
|
||||||
byte_rate = unpacked[3],
|
byte_rate = unpacked[3],
|
||||||
@@ -111,17 +115,30 @@ class WavInfoReader():
|
|||||||
|
|
||||||
def _get_bext(self, f, encoding):
|
def _get_bext(self, f, encoding):
|
||||||
bext_data = self._find_chunk_data(b'bext',f,default_none=True)
|
bext_data = self._find_chunk_data(b'bext',f,default_none=True)
|
||||||
return WavBextReader(bext_data, encoding)
|
if bext_data:
|
||||||
|
return WavBextReader(bext_data, encoding)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
def _get_ixml(self,f):
|
def _get_ixml(self,f):
|
||||||
ixml_data = self._find_chunk_data(b'iXML',f,default_none=True)
|
ixml_data = self._find_chunk_data(b'iXML',f,default_none=True)
|
||||||
if ixml_data is None:
|
if ixml_data is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
ixml_string = ixml_data.decode('utf-8')
|
ixml_string = ixml_data
|
||||||
return WavIXMLFormat(ixml_string)
|
return WavIXMLFormat(ixml_string)
|
||||||
|
|
||||||
|
def walk(self):
|
||||||
|
"""
|
||||||
|
Walk all of the available metadata fields.
|
||||||
|
|
||||||
|
:yields: a string, the :scope: of the metadatum, the string :name: of the
|
||||||
|
metadata field, and the value
|
||||||
|
"""
|
||||||
|
|
||||||
|
scopes = ('fmt','data')#,'bext','ixml','info')
|
||||||
|
|
||||||
|
for scope in scopes:
|
||||||
|
attr = self.__getattribute__(scope)
|
||||||
|
for field in attr._fields:
|
||||||
|
yield scope, field, attr.__getattribute__(field)
|
||||||
|
|||||||
Reference in New Issue
Block a user