Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RIFF/WAVE support, using ID3v2 #321

Closed
wants to merge 37 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
f69a539
Initial support for RIFF/WAVE format, using ID3v2 tags
Borewit Aug 16, 2017
bc6a9a7
quodlibet/mutagen#207: Add WAVE file type
Borewit Aug 16, 2017
404d55c
quodlibet/mutagen#207: fix indentation
Borewit Aug 16, 2017
9d65f6f
quodlibet/mutagen#207: fix blank line
Borewit Aug 16, 2017
793db3c
Fix import statement quodlibet/mutagen#207
Borewit Aug 16, 2017
a36c3a6
quodlibet/mutagen#207: respect E501 max line length of 79
Borewit Aug 16, 2017
1f1f72b
quodlibet/mutagen#207: Normalize ID3v2 chunk to lowercase 'id3 ' chunk
Borewit Aug 17, 2017
0a1ea1c
Comply with 79 max-line-length
Borewit Aug 17, 2017
95ba3a8
quodlibet/mutagen#207: changed access scope of __fileobj from private…
Borewit Aug 17, 2017
bac3bcb
Put assert isinstance(id_, text_type) in every function
Borewit Aug 17, 2017
cd2446b
quodlibet/mutagen#207: disable chunk-id text assertion
Borewit Aug 17, 2017
4190246
Remove text_type from import
Borewit Aug 17, 2017
9877083
Improve code quality.
Borewit Aug 21, 2017
81d0de0
Check all of the 4 characters of the FOURCC
Borewit Aug 21, 2017
449ef0d
Add generic FileType tests
Borewit Aug 22, 2017
392cb98
Remove py3 code
Borewit Aug 22, 2017
2f1e005
Fix missing space in RIFF ID3-chunk-ID
Borewit Aug 26, 2017
e8e73c5
KeyError => ValueError
Borewit Aug 26, 2017
0e904f4
Fix code style issues
Borewit Aug 26, 2017
8b097ac
Try to fix: isinstance(id, text_type) return false on chunk-id which …
Borewit Aug 26, 2017
21552ef
Remove unused import
Borewit Sep 6, 2017
26d7670
Fix lint errors
Borewit Sep 17, 2017
22a9a05
Restore test file to include all tests
Borewit Feb 8, 2018
62f7eb0
Fix unit tests loading RIFF/WAVE by throwing MutagenError instead of
Borewit Feb 8, 2018
1be502a
Fix function RiffFile.insert_chunk:
Borewit Feb 28, 2018
825da85
Fix endianness off RIFF size field (should be Little-Endian, was Big-…
Borewit Feb 28, 2018
395911b
Normalize RIFF ID (FourCC) to exclude spaces on 'bean' level
Borewit Feb 28, 2018
beec64f
Remove ID3 header remainder
Borewit Mar 1, 2018
a09c826
Add a unit test mocking Picard saving an ID3 header to a WAVE file.
Borewit Mar 1, 2018
c4374b8
Resolve review
Borewit Sep 2, 2019
9c2fd9b
Wave: Fixed style errors
phw Sep 11, 2018
e507764
wave: Fixed tests
phw Sep 11, 2018
b3abeeb
Wave: Python 2 fixes
phw Sep 12, 2018
51cf54b
wave: Code review fixes
phw Oct 15, 2019
9d29fbd
wave: Added API docs
phw Oct 15, 2019
52274d7
wave: Renamed sample_size to bits_per_sample
phw Oct 15, 2019
e4f54e5
riff: Minor documentation fixes
phw Oct 17, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -9,6 +9,7 @@ coverage
.cache
*.egg-info
.coverage
*.iml
.idea
.hypothesis
.pytest_cache
1 change: 1 addition & 0 deletions docs/api/index.rst
Expand Up @@ -25,4 +25,5 @@ API Reference
smf
trueaudio
vcomment
wave
wavpack
11 changes: 11 additions & 0 deletions docs/api/wave.rst
@@ -0,0 +1,11 @@
WAVE
----

.. automodule:: mutagen.wave

.. autoclass:: mutagen.wave.WAVE(filename)
:show-inheritance:
:members:

.. autoclass:: mutagen.wave.WaveStreamInfo()
:members:
3 changes: 2 additions & 1 deletion mutagen/_file.py
Expand Up @@ -266,10 +266,11 @@ def File(filething, options=None, easy=False):
from mutagen.aac import AAC
from mutagen.smf import SMF
from mutagen.dsf import DSF
from mutagen.wave import WAVE
options = [MP3, TrueAudio, OggTheora, OggSpeex, OggVorbis, OggFLAC,
FLAC, AIFF, APEv2File, MP4, ID3FileType, WavPack,
Musepack, MonkeysAudio, OptimFROG, ASF, OggOpus, AAC,
SMF, DSF]
SMF, DSF, WAVE]

if not options:
return None
Expand Down
208 changes: 208 additions & 0 deletions mutagen/_riff.py
@@ -0,0 +1,208 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2017 Borewit
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.

"""Resource Interchange File Format (RIFF)."""

import struct
from struct import pack

from ._compat import text_type

from mutagen._util import resize_bytes, delete_bytes, MutagenError


class error(MutagenError):
pass


class InvalidChunk(error):
pass


def is_valid_chunk_id(id):
""" is_valid_chunk_id(FOURCC)

Arguments:
id (FOURCC)
Returns:
true if valid; otherwise false

Check if argument id is valid FOURCC type.
"""

assert isinstance(id, text_type), \
'id is of type %s, must be text_type: %r' % (type(id), id)

if len(id) < 3 or len(id) > 4:
return False

for c in id:
if c < u'!' or c > u'~':
return False

return True


# Assert FOURCC formatted valid
def assert_valid_chunk_id(id):
if not is_valid_chunk_id(id):
raise ValueError("Invalid RIFF-chunk-ID.")


class RiffChunkHeader(object):
""" RIFF chunk header"""

# Chunk headers are 8 bytes long (4 for ID and 4 for the size)
HEADER_SIZE = 8

def __init__(self, fileobj, parent_chunk):
self.__fileobj = fileobj
self.parent_chunk = parent_chunk
self.offset = fileobj.tell()

header = fileobj.read(self.HEADER_SIZE)
if len(header) < self.HEADER_SIZE:
raise InvalidChunk('Header size < %i' % self.HEADER_SIZE)

self.id, self.data_size = struct.unpack('<4sI', header)

try:
self.id = self.id.decode('ascii').rstrip()
except UnicodeDecodeError as e:
raise InvalidChunk(e)

if not is_valid_chunk_id(self.id):
raise InvalidChunk('Invalid chunk ID %s' % self.id)

self.size = self.HEADER_SIZE + self.data_size
self.data_offset = fileobj.tell()

def read(self):
"""Read the chunks data"""

self.__fileobj.seek(self.data_offset)
return self.__fileobj.read(self.data_size)

def write(self, data):
"""Write the chunk data"""

if len(data) > self.data_size:
raise ValueError

self.__fileobj.seek(self.data_offset)
self.__fileobj.write(data)

def delete(self):
"""Removes the chunk from the file"""

delete_bytes(self.__fileobj, self.size, self.offset)
if self.parent_chunk is not None:
self.parent_chunk._update_size(
self.parent_chunk.data_size - self.size)

def _update_size(self, data_size):
"""Update the size of the chunk"""

self.__fileobj.seek(self.offset + 4)
self.__fileobj.write(pack('<I', data_size))
if self.parent_chunk is not None:
size_diff = self.data_size - data_size
self.parent_chunk._update_size(
self.parent_chunk.data_size - size_diff)
self.data_size = data_size
self.size = data_size + self.HEADER_SIZE

def resize(self, new_data_size):
"""Resize the file and update the chunk sizes"""

resize_bytes(
self.__fileobj, self.data_size, new_data_size, self.data_offset)
self._update_size(new_data_size)


class RiffFile(object):
"""Representation of a RIFF file

Ref: http://www.johnloomis.org/cpe102/asgn/asgn1/riff.html
"""

def __init__(self, fileobj):
self._fileobj = fileobj
self.__subchunks = {}

# Reset read pointer to beginning of RIFF file
fileobj.seek(0)

# RIFF Files always start with the RIFF chunk
self._riff_chunk = RiffChunkHeader(fileobj, parent_chunk=None)

if (self._riff_chunk.id != 'RIFF'):
raise KeyError("Root chunk should be a RIFF chunk.")

# Read the RIFF file Type
try:
self.file_type = fileobj.read(4).decode('ascii')
except UnicodeDecodeError as e:
raise error(e)
self.__next_offset = fileobj.tell()

# Load all RIFF subchunks
while True:
try:
chunk = RiffChunkHeader(fileobj, self._riff_chunk)
except InvalidChunk:
break
# Normalize ID3v2-tag-chunk to lowercase
if chunk.id == 'ID3':
chunk.id = 'id3'
self.__subchunks[chunk.id] = chunk

# Calculate the location of the next chunk,
# considering the pad byte
self.__next_offset = chunk.offset + chunk.size
self.__next_offset += self.__next_offset % 2
fileobj.seek(self.__next_offset)

def __contains__(self, id_):
"""Check if the RIFF file contains a specific chunk"""

assert_valid_chunk_id(id_)
return id_ in self.__subchunks

def __getitem__(self, id_):
"""Get a chunk from the RIFF file"""

assert_valid_chunk_id(id_)

try:
return self.__subchunks[id_]
except KeyError:
raise KeyError("%r has no %r chunk" % (self._fileobj, id_))

def delete_chunk(self, id_):
"""Remove a chunk from the RIFF file"""

assert_valid_chunk_id(id_)
self.__subchunks.pop(id_).delete()

def insert_chunk(self, id_):
"""Insert a new chunk at the end of the RIFF file"""

assert isinstance(id_, text_type)

if not is_valid_chunk_id(id_):
raise KeyError("Invalid RIFF key.")

self._fileobj.seek(self.__next_offset)
self._fileobj.write(pack('<4si', id_.ljust(4).encode('ascii'), 0))
self._fileobj.seek(self.__next_offset)
chunk = RiffChunkHeader(self._fileobj, self._riff_chunk)
self._riff_chunk._update_size(self._riff_chunk.data_size + chunk.size)

self.__subchunks[id_] = chunk
self.__next_offset = chunk.offset + chunk.size