feat(voice): add local wake response prompt
Enable the packaged wake response prompt by default and add the MP3-to-device-Opus conversion utility.
This commit is contained in:
@@ -18,8 +18,7 @@
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifndef CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE
|
#ifndef CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE
|
||||||
#define CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE \
|
#define CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE "local_wake_response.opus"
|
||||||
"/littlefs/audio_prompts/local_wake_response.opus"
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifndef CONFIG_TQ_LOCAL_WAKE_RETRY_MS
|
#ifndef CONFIG_TQ_LOCAL_WAKE_RETRY_MS
|
||||||
|
|||||||
Binary file not shown.
@@ -889,20 +889,18 @@ menu "Example Configuration"
|
|||||||
config TQ_LOCAL_WAKE_RESPONSE_PROMPT_ENABLE
|
config TQ_LOCAL_WAKE_RESPONSE_PROMPT_ENABLE
|
||||||
bool "Enable local wake response prompt"
|
bool "Enable local wake response prompt"
|
||||||
depends on TQ_LOCAL_WAKE_ENABLE
|
depends on TQ_LOCAL_WAKE_ENABLE
|
||||||
default n
|
default y
|
||||||
help
|
help
|
||||||
Play a short acknowledgement after 喵掌柜 is detected and
|
Play a short acknowledgement after 喵掌柜 is detected and
|
||||||
before the normal single-turn dialog starts. Keep disabled
|
before the normal single-turn dialog starts.
|
||||||
until the placeholder Opus asset has been supplied.
|
|
||||||
|
|
||||||
config TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE
|
config TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE
|
||||||
string "Local wake response prompt file"
|
string "Local wake response prompt file"
|
||||||
depends on TQ_LOCAL_WAKE_ENABLE
|
depends on TQ_LOCAL_WAKE_ENABLE
|
||||||
default "/littlefs/audio_prompts/local_wake_response.opus"
|
default "local_wake_response.opus"
|
||||||
help
|
help
|
||||||
Reserved LittleFS path for the future wake acknowledgement.
|
Prompt filename relative to /littlefs/audio_prompts. Absolute
|
||||||
Add local_wake_response.opus under
|
LittleFS paths are also supported.
|
||||||
components/platform/assets/audio_prompts before enabling it.
|
|
||||||
|
|
||||||
config TQ_LOCAL_WAKE_RETRY_MS
|
config TQ_LOCAL_WAKE_RETRY_MS
|
||||||
int "Local wake startup retry delay (ms)"
|
int "Local wake startup retry delay (ms)"
|
||||||
|
|||||||
+2
-2
@@ -60,8 +60,8 @@ CONFIG_TQ_PTT_MODE_CLICK_TO_TALK=y
|
|||||||
# CONFIG_TQ_PTT_MODE_HOLD_TO_TALK is not set
|
# CONFIG_TQ_PTT_MODE_HOLD_TO_TALK is not set
|
||||||
CONFIG_TQ_PTT_CLICK_CAPTURE_TIMEOUT_MS=8000
|
CONFIG_TQ_PTT_CLICK_CAPTURE_TIMEOUT_MS=8000
|
||||||
CONFIG_TQ_LOCAL_WAKE_ENABLE=y
|
CONFIG_TQ_LOCAL_WAKE_ENABLE=y
|
||||||
# CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_ENABLE is not set
|
CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_ENABLE=y
|
||||||
CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE="/littlefs/audio_prompts/local_wake_response.opus"
|
CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE="local_wake_response.opus"
|
||||||
CONFIG_TQ_LOCAL_WAKE_RETRY_MS=5000
|
CONFIG_TQ_LOCAL_WAKE_RETRY_MS=5000
|
||||||
CONFIG_TQ_LOCAL_WAKE_STOP_WAIT_MS=1000
|
CONFIG_TQ_LOCAL_WAKE_STOP_WAIT_MS=1000
|
||||||
CONFIG_TQ_LOCAL_WAKE_TASK_STACK_BYTES=6144
|
CONFIG_TQ_LOCAL_WAKE_TASK_STACK_BYTES=6144
|
||||||
|
|||||||
@@ -50,11 +50,14 @@ fi
|
|||||||
if ! rg -n 'TQ_LOCAL_WAKE_RESPONSE_PROMPT_ENABLE' \
|
if ! rg -n 'TQ_LOCAL_WAKE_RESPONSE_PROMPT_ENABLE' \
|
||||||
main/Kconfig.projbuild \
|
main/Kconfig.projbuild \
|
||||||
components/control_plane/src/multimodal/control_plane_multimodal_local_wake.c >/dev/null 2>&1 ||
|
components/control_plane/src/multimodal/control_plane_multimodal_local_wake.c >/dev/null 2>&1 ||
|
||||||
! rg -n '/littlefs/audio_prompts/local_wake_response\.opus' \
|
! rg -n 'CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_ENABLE=y' \
|
||||||
|
sdkconfig.defaults >/dev/null 2>&1 ||
|
||||||
|
! rg -n 'local_wake_response\.opus' \
|
||||||
main/Kconfig.projbuild \
|
main/Kconfig.projbuild \
|
||||||
sdkconfig.defaults \
|
sdkconfig.defaults \
|
||||||
components/control_plane/src/multimodal/control_plane_multimodal_local_wake.c >/dev/null 2>&1; then
|
components/control_plane/src/multimodal/control_plane_multimodal_local_wake.c >/dev/null 2>&1 ||
|
||||||
fail "Local wake response prompt must keep its disabled asset placeholder contract"
|
[[ ! -f components/platform/assets/audio_prompts/local_wake_response.opus ]]; then
|
||||||
|
fail "Local wake response prompt must remain enabled with its packaged asset contract"
|
||||||
fi
|
fi
|
||||||
if ! rg -n '#include "esp_afe_aec.h"' components/platform/src/multimodal/audio_pipeline.c >/dev/null 2>&1 ||
|
if ! rg -n '#include "esp_afe_aec.h"' components/platform/src/multimodal/audio_pipeline.c >/dev/null 2>&1 ||
|
||||||
! rg -n 'afe_aec_create\(' components/platform/src/multimodal/audio_pipeline/audio_pipeline_codec_impl.c >/dev/null 2>&1 ||
|
! rg -n 'afe_aec_create\(' components/platform/src/multimodal/audio_pipeline/audio_pipeline_codec_impl.c >/dev/null 2>&1 ||
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
#!/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())
|
||||||
Reference in New Issue
Block a user