Files
lucky-cat/tools/convert_mp3_to_prompt_opus.py
T
chenjintao 617255bdab feat(voice): add local wake response prompt
Enable the packaged wake response prompt by default and add the MP3-to-device-Opus conversion utility.
2026-08-10 17:40:12 +08:00

345 lines
11 KiB
Python

#!/usr/bin/env python3
"""Convert an audio file into the device's length-prefixed raw Opus format.
The runtime prompt player does not consume an Ogg Opus file directly. It
expects a sequence of records with this layout:
uint32_be packet_size
uint8_t raw_opus_packet[packet_size]
FFmpeg is used only for decoding/resampling and Opus encoding. This script
then removes the Ogg container and writes the framing expected by the device.
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import shutil
import struct
import subprocess
import sys
import tempfile
from typing import BinaryIO, Iterable, Iterator
SAMPLE_RATE_HZ = 16_000
CHANNELS = 1
FRAME_DURATION_MS = 60
DEFAULT_BITRATE_KBPS = 24
MAX_PACKET_BYTES = 2_048
OGG_CAPTURE_PATTERN = b"OggS"
class ConversionError(RuntimeError):
"""Raised when conversion input or output violates the prompt contract."""
def _read_exact(stream: BinaryIO, size: int, description: str) -> bytes:
data = stream.read(size)
if len(data) != size:
raise ConversionError(f"truncated {description}")
return data
def iter_ogg_packets(path: Path) -> Iterator[bytes]:
"""Yield packets from the single logical Ogg stream generated by FFmpeg."""
packet = bytearray()
stream_serial: int | None = None
expected_sequence: int | None = None
with path.open("rb") as stream:
while True:
capture = stream.read(4)
if capture == b"":
break
if capture != OGG_CAPTURE_PATTERN:
raise ConversionError("invalid Ogg capture pattern")
fixed_header = _read_exact(stream, 23, "Ogg page header")
version = fixed_header[0]
header_type = fixed_header[1]
serial = struct.unpack_from("<I", fixed_header, 10)[0]
sequence = struct.unpack_from("<I", fixed_header, 14)[0]
segment_count = fixed_header[22]
if version != 0:
raise ConversionError(f"unsupported Ogg version: {version}")
if stream_serial is None:
stream_serial = serial
expected_sequence = sequence
if serial != stream_serial:
raise ConversionError("multiple Ogg logical streams are unsupported")
if expected_sequence is not None and sequence != expected_sequence:
raise ConversionError(
f"unexpected Ogg page sequence: {sequence}, "
f"expected {expected_sequence}"
)
expected_sequence = sequence + 1
continued = (header_type & 0x01) != 0
if continued != bool(packet):
raise ConversionError("invalid Ogg continued-packet flag")
lacing = _read_exact(stream, segment_count, "Ogg segment table")
page_body = _read_exact(stream, sum(lacing), "Ogg page body")
offset = 0
for segment_size in lacing:
packet.extend(page_body[offset : offset + segment_size])
offset += segment_size
if segment_size < 255:
yield bytes(packet)
packet.clear()
if packet:
raise ConversionError("truncated Ogg packet at end of file")
def opus_packet_duration_us(packet: bytes) -> int:
"""Return an Opus packet duration using the TOC rules from RFC 6716."""
if not packet:
raise ConversionError("empty Opus packet")
toc = packet[0]
config = toc >> 3
if config >= 16:
frame_duration_us = 2_500 << (config & 0x03)
elif config >= 12:
frame_duration_us = 10_000 << (config & 0x01)
elif (config & 0x03) == 0x03:
frame_duration_us = 60_000
else:
frame_duration_us = 10_000 << (config & 0x03)
frame_code = toc & 0x03
if frame_code == 0:
frame_count = 1
elif frame_code in (1, 2):
frame_count = 2
else:
if len(packet) < 2:
raise ConversionError("truncated Opus packet frame-count byte")
frame_count = packet[1] & 0x3F
if frame_count == 0:
raise ConversionError("invalid zero-frame Opus packet")
duration_us = frame_duration_us * frame_count
if duration_us > 120_000:
raise ConversionError(
f"Opus packet duration exceeds 120 ms: {duration_us / 1000:g} ms"
)
return duration_us
def validate_raw_prompt_packets(packets: Iterable[bytes]) -> tuple[int, int]:
packet_count = 0
payload_bytes = 0
expected_duration_us = FRAME_DURATION_MS * 1_000
for index, packet in enumerate(packets, start=1):
if len(packet) > MAX_PACKET_BYTES:
raise ConversionError(
f"packet {index} is {len(packet)} bytes; maximum is "
f"{MAX_PACKET_BYTES}"
)
duration_us = opus_packet_duration_us(packet)
if duration_us != expected_duration_us:
raise ConversionError(
f"packet {index} duration is {duration_us / 1000:g} ms; "
f"expected {FRAME_DURATION_MS} ms"
)
packet_count += 1
payload_bytes += len(packet)
if packet_count == 0:
raise ConversionError("no audio packets were produced")
return packet_count, payload_bytes
def read_length_prefixed_packets(path: Path) -> Iterator[bytes]:
with path.open("rb") as stream:
while True:
header = stream.read(4)
if header == b"":
break
if len(header) != 4:
raise ConversionError("truncated prompt packet header")
packet_size = struct.unpack(">I", header)[0]
if packet_size == 0 or packet_size > MAX_PACKET_BYTES:
raise ConversionError(
f"invalid prompt packet size: {packet_size}"
)
yield _read_exact(stream, packet_size, "prompt packet payload")
def write_length_prefixed_packets(path: Path, packets: Iterable[bytes]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb", prefix=f".{path.name}.", dir=path.parent, delete=False
) as output:
temporary_path = Path(output.name)
for packet in packets:
output.write(struct.pack(">I", len(packet)))
output.write(packet)
os.replace(temporary_path, path)
temporary_path = None
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
def encode_ogg_opus(
ffmpeg: str, input_path: Path, output_path: Path, bitrate_kbps: int
) -> None:
command = [
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
str(input_path),
"-map",
"0:a:0",
"-vn",
"-map_metadata",
"-1",
"-ac",
str(CHANNELS),
"-ar",
str(SAMPLE_RATE_HZ),
"-c:a",
"libopus",
"-application",
"voip",
"-frame_duration",
str(FRAME_DURATION_MS),
"-b:a",
f"{bitrate_kbps}k",
"-vbr",
"off",
"-compression_level",
"10",
str(output_path),
]
try:
subprocess.run(command, check=True)
except FileNotFoundError as error:
raise ConversionError(f"ffmpeg executable not found: {ffmpeg}") from error
except subprocess.CalledProcessError as error:
raise ConversionError(f"ffmpeg failed with exit code {error.returncode}") from error
def convert(
input_path: Path,
output_path: Path,
ffmpeg: str,
bitrate_kbps: int,
overwrite: bool,
) -> tuple[int, int]:
if not input_path.is_file():
raise ConversionError(f"input file does not exist: {input_path}")
if output_path.exists() and not overwrite:
raise ConversionError(
f"output already exists: {output_path}; pass --force to replace it"
)
if not 6 <= bitrate_kbps <= 510:
raise ConversionError("bitrate must be between 6 and 510 kbps")
resolved_ffmpeg = shutil.which(ffmpeg)
if resolved_ffmpeg is None:
raise ConversionError(
f"ffmpeg executable not found: {ffmpeg}; install FFmpeg or pass "
"--ffmpeg /absolute/path/to/ffmpeg"
)
with tempfile.TemporaryDirectory(prefix="tq_prompt_opus_") as temp_dir:
ogg_path = Path(temp_dir) / "encoded.ogg"
encode_ogg_opus(
resolved_ffmpeg, input_path, ogg_path, bitrate_kbps
)
ogg_packets = list(iter_ogg_packets(ogg_path))
if len(ogg_packets) < 3 or not ogg_packets[0].startswith(b"OpusHead") or not ogg_packets[1].startswith(b"OpusTags"):
raise ConversionError("FFmpeg output is missing OpusHead/OpusTags packets")
audio_packets = ogg_packets[2:]
packet_count, payload_bytes = validate_raw_prompt_packets(audio_packets)
write_length_prefixed_packets(output_path, audio_packets)
return packet_count, payload_bytes
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Convert MP3 or another FFmpeg-supported audio file into the "
"length-prefixed raw Opus prompt format used by TQ_CatN."
)
)
parser.add_argument("input", nargs="?", type=Path, help="source audio file")
parser.add_argument("output", nargs="?", type=Path, help="destination .opus file")
parser.add_argument(
"--bitrate-kbps",
type=int,
default=DEFAULT_BITRATE_KBPS,
help=f"Opus bitrate in kbps (default: {DEFAULT_BITRATE_KBPS})",
)
parser.add_argument(
"--ffmpeg",
default="ffmpeg",
help="FFmpeg executable name or absolute path (default: ffmpeg)",
)
parser.add_argument(
"--force", action="store_true", help="replace an existing output file"
)
parser.add_argument(
"--validate-only",
type=Path,
metavar="PROMPT_OPUS",
help="validate an existing device prompt without invoking FFmpeg",
)
args = parser.parse_args()
if args.validate_only is None and (args.input is None or args.output is None):
parser.error("input and output are required unless --validate-only is used")
if args.validate_only is not None and (args.input is not None or args.output is not None):
parser.error("input/output cannot be combined with --validate-only")
return args
def main() -> int:
args = parse_args()
try:
if args.validate_only is not None:
packet_count, payload_bytes = validate_raw_prompt_packets(
read_length_prefixed_packets(args.validate_only)
)
output_path = args.validate_only
action = "Validated"
else:
packet_count, payload_bytes = convert(
args.input,
args.output,
args.ffmpeg,
args.bitrate_kbps,
args.force,
)
output_path = args.output
action = "Created"
except (ConversionError, OSError) as error:
print(f"error: {error}", file=sys.stderr)
return 1
print(
f"{action} {output_path}: {packet_count} packets, "
f"{payload_bytes} Opus bytes, {SAMPLE_RATE_HZ} Hz mono, "
f"{FRAME_DURATION_MS} ms frames"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())