Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,20 @@ $ m4b-util cover /path/to/book.m4b --extract /path/to/old/cover.png --apply-cove
```

### Labels
The `labels` command converts between Audacity labels, FFMPEG metadata, and Audiobook chapter metadata. Label end times
are ignored, as audiobooks need contiguous, non-overlapping chapters. When converting from a label file, the end time
The `labels` command converts between Audacity labels, cue sheets, FFMPEG metadata, and Audiobook chapter metadata. Label end times
are ignored, as audiobooks need contiguous, non-overlapping chapters. When converting from a label file, the end time
of each segment is set from the start time of the next segment.

**Example:**
```shell
$ m4b-util labels --from-label-file /path/to/labels.txt --to-book /path/to/existing/book.m4b --to-metadata-file /path/to/new_labels.txt
```

**Example:**
```shell
$ m4b-util labels --from-cue-file /path/to/chapters.cue --to-cue-file /path/to/new.cue
```

**Example:**
```shell
$ m4b-util bind /path/to/inputs --title "My Book" --cover /path/to/cover.png -e m4a -e .mp4 --output-dir /path/to/output
Expand Down
6 changes: 6 additions & 0 deletions src/m4b_util/helpers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
"""A Package full of helper functions."""
from .audiobook import Audiobook # noqa: F401
from .segment_data import SegmentData # noqa: F401
from .cue import (
cue_from_segment_data, # noqa: F401
cue_time_to_seconds, # noqa: F401
seconds_to_cue_time, # noqa: F401
segment_data_from_cue, # noqa: F401
)
68 changes: 68 additions & 0 deletions src/m4b_util/helpers/cue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Cue sheet conversion utilities."""

from __future__ import annotations

# Standard Library
import re
from typing import Iterable, List

from .segment_data import SegmentData


def cue_time_to_seconds(cue_time: str) -> float:
"""Convert cue sheet time into seconds."""
minute, second, frame = [int(x) for x in cue_time.split(":")]
return minute * 60 + second + frame / 75


def seconds_to_cue_time(seconds: float) -> str:
"""Convert seconds into cue sheet time."""
total_frames = round(seconds * 75)
minutes, remainder = divmod(total_frames, 75 * 60)
secs, frames = divmod(remainder, 75)
return f"{minutes:02d}:{secs:02d}:{frames:02d}"


def segment_data_from_cue(lines: Iterable[str]) -> List[SegmentData]:
"""Convert cue sheet lines to a list of segments."""
segments: List[SegmentData] = []
previous = None
current_title = None
cue_regex = re.compile(r"^\s*INDEX\s+01\s+(?P<time>\d{2}:\d{2}:\d{2})")
title_regex = re.compile(r"^\s*TITLE\s+\"?(?P<title>.+?)\"?$")
for line in lines:
title_match = title_regex.search(line)
if title_match:
current_title = title_match["title"]
continue
match = cue_regex.search(line)
if match:
if previous:
segments.append(
SegmentData(
start_time=previous["start"],
end_time=cue_time_to_seconds(match["time"]),
title=previous["title"],
)
)
previous = {"start": cue_time_to_seconds(match["time"]), "title": current_title}
if previous:
segments.append(
SegmentData(
start_time=previous["start"],
end_time=previous["start"],
title=previous["title"],
)
)
return segments


def cue_from_segment_data(segments: Iterable[SegmentData], file_name: str = "output") -> List[str]:
"""Generate cue sheet lines from a list of segments."""
lines = [f'FILE "{file_name}" WAVE']
for i, segment in enumerate(segments, start=1):
lines.append(f" TRACK {i:02d} AUDIO")
if segment.title:
lines.append(f' TITLE "{segment.title}"')
lines.append(f" INDEX 01 {seconds_to_cue_time(segment.start_time)}")
return lines
18 changes: 16 additions & 2 deletions src/m4b_util/subcommands/labels.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Standard Library
import argparse
from pathlib import Path
import re
import sys

# Third Party
Expand All @@ -10,24 +9,32 @@

# Local
from ..helpers import Audiobook, ffprobe, SegmentData
from ..helpers.cue import (
cue_from_segment_data,
cue_time_to_seconds,
seconds_to_cue_time,
segment_data_from_cue,
)
from ..helpers.ffmetadata import FFMETADATA_TERMINATOR_FRIENDLY_NAMES


def _parse_args():
parser = argparse.ArgumentParser(
prog="m4b-util load-labels",
description="Convert between audacity labels and ffmpeg chapter metadata."
description="Convert between audacity labels, cue sheets, and ffmpeg chapter metadata."
)
# Inputs
input_options = parser.add_mutually_exclusive_group(required=True)
input_options.add_argument("--from-book", help="Read chapters from file.")
input_options.add_argument("--from-label-file", help="Read audacity labels from text file.")
input_options.add_argument("--from-metadata-file", help="Read ffmpeg metadata from file.")
input_options.add_argument("--from-cue-file", help="Read cue sheet from file.")

# Outputs
output_options = parser.add_argument_group("output options")
output_options.add_argument("--to-metadata-file", type=str, help="Output ffmpeg metadata to file.")
output_options.add_argument("--to-label-file", type=str, help="Output labels to file.")
output_options.add_argument("--to-cue-file", type=str, help="Output cue sheet to file.")
output_options.add_argument("--to-book", type=str, help="Apply labels as chapters to existing book file.")

args = parser.parse_args(sys.argv[2:])
Expand Down Expand Up @@ -85,6 +92,9 @@ def _handle_input(args, book):
with open(args.from_label_file) as f:
labels = f.readlines()
book.chapters = segment_data_from_labels(labels)
elif args.from_cue_file:
with open(args.from_cue_file) as f:
book.chapters = segment_data_from_cue(f.readlines())
elif args.from_book:
book.add_chapters_from_chaptered_file(args.from_book)
else: # args.from_metadata_file:
Expand All @@ -107,6 +117,10 @@ def _handle_output(args, book):
with open(args.to_label_file, 'w') as f:
for label in labels_from_segment_data(book.chapters):
f.write(f"{label}\n")
if args.to_cue_file:
with open(args.to_cue_file, 'w') as f:
for line in cue_from_segment_data(book.chapters, Path(args.to_cue_file).name):
f.write(f"{line}\n")
if args.to_metadata_file:
with open(args.to_metadata_file, 'w') as f:
f.write(book.metadata)
Expand Down
70 changes: 69 additions & 1 deletion tests/subcommands/test_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
import pytest
import testhelpers

from m4b_util.helpers import ffprobe, SegmentData
from m4b_util.helpers import (
ffprobe,
SegmentData,
cue_from_segment_data,
cue_time_to_seconds,
seconds_to_cue_time,
segment_data_from_cue,
)
from m4b_util.subcommands import labels


Expand All @@ -24,6 +31,20 @@ def label_file_path(tmp_path):
return label_file


@pytest.fixture
def cue_file_path(tmp_path):
"""A generic cue file."""
cue_file = tmp_path / "labels.cue"
times = [0.0, 2.5, 5.0, 7.5, 10.0, 12.5, 15.0, 17.6]
with open(cue_file, "w") as f:
f.write('FILE "book" WAVE\n')
for i, t in enumerate(times, start=1):
f.write(f" TRACK {i:02d} AUDIO\n")
f.write(f" TITLE \"{i} - {i * 110}Hz\"\n")
f.write(f" INDEX 01 {seconds_to_cue_time(t)}\n")
return cue_file


def _run_labels_cmd(arg_list):
"""Patch the runtime arguments, then run the split command."""
argv_patch = ["m4b-util", "labels"]
Expand Down Expand Up @@ -104,6 +125,33 @@ def test_labels_from_segment_data():
assert actual == expected


def test_segment_data_from_cue(cue_file_path):
"""Convert cue sheet to a list of segment data."""
times = [0.0, 2.5, 5.0, 7.5, 10.0, 12.5, 15.0, 17.6]
converted = [cue_time_to_seconds(seconds_to_cue_time(t)) for t in times]
expected = []
for i, start in enumerate(converted, start=1):
end = converted[i] if i < len(converted) else converted[i - 1]
expected.append(SegmentData(start_time=start, end_time=end, title=f"{i} - {i * 110}Hz"))
with open(cue_file_path) as f:
lines = f.readlines()
actual = segment_data_from_cue(lines)
assert actual == expected


def test_cue_from_segment_data(cue_file_path):
"""Create a cue sheet from a list of segment data."""
with open(cue_file_path) as f:
segments = segment_data_from_cue(f.readlines())
expected_lines = [f'FILE "labels.cue" WAVE']
for i, seg in enumerate(segments, start=1):
expected_lines.append(f" TRACK {i:02d} AUDIO")
expected_lines.append(f' TITLE "{seg.title}"')
expected_lines.append(f" INDEX 01 {seconds_to_cue_time(seg.start_time)}")
actual = cue_from_segment_data(segments, "labels.cue")
assert actual == expected_lines


def test_labels_from_labels(tmp_path, label_file_path, variable_volume_segments_file_path):
"""Generate all possible outputs from the label command, using a label file as input."""
meta_file_path = tmp_path / "ffmetadata"
Expand Down Expand Up @@ -298,3 +346,23 @@ def test_bad_metadata(tmp_path, capsys):

output = capsys.readouterr()
assert "Parsing metadata failed" in output.out


def test_labels_from_cue(tmp_path, cue_file_path, variable_volume_segments_file_path):
"""Generate output from a cue file."""
cue_out_path = tmp_path / "out.cue"
_run_labels_cmd([
"--from-cue-file", str(cue_file_path),
"--to-cue-file", str(cue_out_path),
"--to-book", str(variable_volume_segments_file_path)
])

with open(cue_file_path) as f:
segments = segment_data_from_cue(f.readlines())
expected = "\n".join(cue_from_segment_data(segments, cue_out_path.name)) + "\n"
with open(cue_out_path) as f:
cuedata = f.read()
assert cuedata == expected

probe = ffprobe.run_probe(variable_volume_segments_file_path)
assert probe and len(probe.chapters) == len(segments)