Skip to content

Commit

Permalink
Merge pull request #23 from letuananh/main
Browse files Browse the repository at this point in the history
Basic ELAN editing, improved doc
  • Loading branch information
letuananh committed May 14, 2021
2 parents e289f66 + 312de03 commit 79f34ce
Show file tree
Hide file tree
Showing 7 changed files with 127 additions and 27 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.idea/
test/data/test.out.*
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Processing media files
```python
>>> from speach import media
>>> media.convert("~/Documents/test.wav", "~/Documents/test.ogg")
>>> media.cut(ELAN_DIR / "test.wav", ELAN_DIR / "test_10-15.ogg", from_ts="00:00:10", to_ts="00:00:15")
>>> media.cut("test.wav", "test_10-15.ogg", from_ts="00:00:10", to_ts="00:00:15")
```

Read [Speach documentation](https://speach.readthedocs.io/) for more information.
44 changes: 41 additions & 3 deletions docs/api_elan.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,54 @@ ELAN module

``speach`` supports reading and manipulating multi-tier transcriptions from ELAN directly.

For common code samples to processing ELAN, see :ref:`tut_elan` page.

For common code samples to processing ELAN, see :ref:`recipe_elan` page.

.. contents:: Table of Contents
:depth: 3

ELAN module functions
---------------------

.. automodule:: speach.elan
:members: read_eaf, parse_eaf_stream
:members: read_eaf, parse_eaf_stream, parse_string
:member-order: bysource

ELAN Document model
-------------------

.. autoclass:: Doc
:members:
:member-order: groupwise
:exclude-members: read_eaf, parse_eaf_stream

ELAN Tier model
---------------

.. autoclass:: Tier
:members:
:member-order: groupwise

ELAN Annotation model
---------------------

There are two different annotation types in ELAN: :class:`TimeAnnotation` and :class:`RefAnnotation`.
TimeAnnotation objects are time-alignable annotations and contain timestamp pairs ``from_ts, to_ts``
to refer back to specific chunks in the source media.
On the other hand, RefAnnotation objects are annotations that link to something else, such as another annotation
or an annotation sequence in the case of symbolic subdivision tiers.

.. autoclass:: TimeAnnotation
:members:
:member-order: groupwise

.. autoclass:: RefAnnotation
:members:
:member-order: groupwise

.. autoclass:: Annotation
:members:
:member-order: groupwise

.. autoclass:: TimeSlot
:members:
:member-order: groupwise
4 changes: 4 additions & 0 deletions docs/make.bat
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ set SOURCEDIR=.
set BUILDDIR=_build

if "%1" == "" goto help
if "%1" == "serve" goto serve

%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
Expand All @@ -31,5 +32,8 @@ goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%

:serve
python -m http.server 7001 -d _build/dirhtml

:end
popd
26 changes: 22 additions & 4 deletions docs/recipe_elan.rst
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.. _tut_elan:
.. _recipe_elan:

ELAN Recipes
============
Expand All @@ -15,10 +15,17 @@ Open an ELAN file
>>> eaf
<speach.elan.Doc object at 0x7f67790593d0>

Save an ELAN transcription to a file
------------------------------------

After edited an :class:`speach.elan.Doc` object, its content can be saved to an EAF file like this

>>> eaf.save("test_edited.eaf")

Parse an existing text stream
-----------------------------

If you have an input stream ready, you can parse its content with :code:`parse_eaf_stream()` method.
If you have an input stream ready, you can parse its content with :meth:`speach.elan.parse_eaf_stream` method.

.. code-block:: python
Expand All @@ -32,7 +39,7 @@ If you have an input stream ready, you can parse its content with :code:`parse_e
Accessing tiers & annotations
-----------------------------

You can loop through all tiers in an ``Doc`` object (i.e. an eaf file)
You can loop through all tiers in an :class:`speach.elan.Doc` object (i.e. an eaf file)
and all annotations in each tier using Python's ``for ... in ...`` loops.
For example:

Expand All @@ -46,7 +53,7 @@ For example:
Accessing nested tiers in ELAN
------------------------------

If you want to loop through the root tiers only, you can use the :code:`roots` list of an ``ELANDoc``:
If you want to loop through the root tiers only, you can use the :code:`roots` list of an :class:`speach.elan.Doc`:

.. code-block:: python
Expand All @@ -59,6 +66,17 @@ If you want to loop through the root tiers only, you can use the :code:`roots` l
for ann in child_tier.annotations:
print(f" |- {ann.ID.rjust(4, ' ')}. [{ann.from_ts} -- {ann.to_ts}] {ann.text}")
Retrieving a tier by name
-------------------------

All tiers are indexed in :class:`speach.elan.Doc` and can be accessed using Python indexer operator.
For example, the following code loop through all annotations in the tier ``Person1 (Utterance)`` and
print out their text values:

>>> p1_tier = eaf["Person1 (Utterance)"]
>>> for ann in p1_tier:
>>> print(ann.text)

Cutting annotations to separate audio files
-------------------------------------------

Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
chirptext >= 0.1a21
puchikarui >= 0.1a4
chirptext >= 0.1, <0.3
puchikarui >= 0.1, <0.3
73 changes: 56 additions & 17 deletions speach/elan.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ def getLogger():

class TimeSlot:

def __init__(self, xml_node=None, *args, **kwargs):
""" An ELAN timestamp (with ID)
def __init__(self, xml_node=None, ID=None, value=None, *args, **kwargs):
""" An ELAN timestamp
"""
self.__xml_node = xml_node
self.__ID = xml_node.get('TIME_SLOT_ID')
_v = xml_node.get('TIME_VALUE')
self.__ID = xml_node.get('TIME_SLOT_ID') if xml_node is not None else ID
_v = xml_node.get('TIME_VALUE') if xml_node is not None else value
self.__value = int(_v) if _v else None

@property
Expand All @@ -61,12 +61,16 @@ def value(self):
return self.__value

@property
def ts(self):
return sec2ts(self.sec) if self.value is not None else None
def ts(self) -> str:
""" Return timestamp of this annotation in vtt format (00:01:02.345)
:return: An empty string will be returned if TimeSlot value is None
"""
return sec2ts(self.sec) if self.value is not None else ''

@property
def sec(self):
""" Get TimeSlot value in seconds instead of milliseconds """
""" Get TimeSlot value in seconds """
return self.value / 1000 if self.value is not None else None

def __lt__(self, other):
Expand Down Expand Up @@ -128,7 +132,17 @@ def ID(self):
return self.__ID

@property
def value(self):
def value(self) -> str:
""" Annotated text value.
It is possible to change value of an annotation
>>> ann.value
'Old value'
>>> ann.value = "New value"
>>> ann.value
'New value'
"""
return self.__value

@value.setter
Expand Down Expand Up @@ -166,15 +180,20 @@ def __init__(self, ID, from_ts, to_ts, value, xml_node=None, **kwargs):
self.__to_ts = to_ts

@property
def from_ts(self):
def from_ts(self) -> TimeSlot:
""" Start timestamp of this annotation
"""
return self.__from_ts

@property
def to_ts(self):
def to_ts(self) -> TimeSlot:
""" End timestamp of this annotation
"""
return self.__to_ts

@property
def duration(self):
def duration(self) -> float:
""" Duration of this annotation (in seconds) """
return self.to_ts.sec - self.from_ts.sec

def overlap(self, other):
Expand Down Expand Up @@ -288,7 +307,6 @@ def ID(self, value):
elif not value:
raise ValueError("Tier ID cannot be empty")
else:
_oldID = self.ID
self.__ID = value
if self.doc is not None:
self.doc._reset_tier_map()
Expand Down Expand Up @@ -958,13 +976,16 @@ def cut(self, section, outfile, media_file=None):

@classmethod
def read_eaf(cls, eaf_path, encoding='utf-8', *args, **kwargs):
""" Read an EAF file
""" Read an EAF file and return an elan.Doc object
>>> from speach import elan
>>> eaf = elan.read_eaf("myfile.eaf")
:param eaf_path: Path to existing EAF file
:type eaf_path: str or Path-like object
:param encoding: Encoding of the eaf stream, defaulted to UTF-8
:type encoding: str
:rtype: speach.elan.Doc
"""
eaf_path = str(eaf_path)
if eaf_path.startswith("~"):
Expand All @@ -974,12 +995,16 @@ def read_eaf(cls, eaf_path, encoding='utf-8', *args, **kwargs):
_doc.path = eaf_path
return _doc

@classmethod
def parse_string(cls, eaf_string, *args, **kwargs):
return cls.parse_eaf_stream(StringIO(eaf_string), *args, **kwargs)

@classmethod
def parse_eaf_stream(cls, eaf_stream, *args, **kwargs):
""" Parse an EAF input stream and return an elan.Doc object
>>> with open('test/data/test.eaf').read() as eaf_stream:
>>> eaf = elan.parse_eaf_stream(eaf_stream)
:param eaf_stream: EAF text input stream
:rtype: speach.elan.Doc
"""
_root = etree.fromstring(eaf_stream.read())
_doc = Doc()
_doc.__xml_root = _root
Expand Down Expand Up @@ -1029,6 +1054,20 @@ def parse_eaf_stream(cls, eaf_stream, *args, **kwargs):
ann.resolve(_doc)
return _doc

@classmethod
def parse_string(cls, eaf_string, *args, **kwargs):
""" Parse EAF content in a string and return an elan.Doc object
>>> with open('test/data/test.eaf').read() as eaf_stream:
>>> eaf_content = eaf_stream.read()
>>> eaf = elan.parse_string(eaf_content)
:param eaf_string: EAF content stored in a string
:type eaf_string: str
:rtype: speach.elan.Doc
"""
return cls.parse_eaf_stream(StringIO(eaf_string), *args, **kwargs)


read_eaf = Doc.read_eaf
parse_eaf_stream = Doc.parse_eaf_stream
Expand Down

0 comments on commit 79f34ce

Please sign in to comment.