Textcast works.

This commit is contained in:
2026-05-27 14:27:29 +02:00
parent 50761a4b37
commit d1471fae79
5 changed files with 450 additions and 11 deletions
+72
View File
@@ -0,0 +1,72 @@
"""DCP XML subtitle file parser (Interop and SMPTE 428-7 formats).
Timecode format: HH:MM:SS:FF (frame-based, default 24 fps)
HH:MM:SS.mmm (millisecond decimal, also accepted)
"""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from typing import List
@dataclass
class Subtitle:
time_in: float # seconds (float)
time_out: float # seconds (float)
text: str
def _parse_timecode(tc: str, fps: int = 24) -> float:
"""Parse a DCP timecode string to float seconds."""
# HH:MM:SS:FF
m = re.match(r'^(\d+):(\d+):(\d+):(\d+)$', tc.strip())
if m:
h, mi, s, f = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
return h * 3600 + mi * 60 + s + f / fps
# HH:MM:SS.mmm
m = re.match(r'^(\d+):(\d+):(\d+)\.(\d+)$', tc.strip())
if m:
h, mi, s = int(m.group(1)), int(m.group(2)), int(m.group(3))
frac = float('0.' + m.group(4))
return h * 3600 + mi * 60 + s + frac
raise ValueError(f"Unrecognized DCP timecode: {tc!r}")
def parse_dcp_xml(path: str, fps: int = 24) -> List[Subtitle]:
"""Parse a DCP XML subtitle file and return a time-sorted list of Subtitles."""
tree = ET.parse(path)
root = tree.getroot()
# Strip namespace so element lookups work regardless of schema version
ns_match = re.match(r'\{(.+?)\}', root.tag)
ns = ns_match.group(0) if ns_match else ''
subtitles: List[Subtitle] = []
for subtitle_el in root.iter(f'{ns}Subtitle'):
time_in_str = subtitle_el.get('TimeIn', '')
time_out_str = subtitle_el.get('TimeOut', '')
if not time_in_str or not time_out_str:
continue
parts: List[str] = []
for text_el in subtitle_el.iter(f'{ns}Text'):
t = (text_el.text or '').strip()
if t:
parts.append(t)
text = ' '.join(parts)
if not text:
continue
subtitles.append(Subtitle(
time_in=_parse_timecode(time_in_str, fps),
time_out=_parse_timecode(time_out_str, fps),
text=text,
))
return sorted(subtitles, key=lambda s: s.time_in)
+70 -11
View File
@@ -141,6 +141,7 @@ except Exception:
# Define is_streaming early from the fetched status for use throughout the UI # Define is_streaming early from the fetched status for use throughout the UI
is_streaming = bool(saved_settings.get("is_streaming", False)) is_streaming = bool(saved_settings.get("is_streaming", False))
textcast_is_streaming = bool(saved_settings.get("textcast_is_streaming", False))
# Extract secondary status, if provided by the backend /status endpoint. # Extract secondary status, if provided by the backend /status endpoint.
secondary_status = saved_settings.get("secondary") or {} secondary_status = saved_settings.get("secondary") or {}
@@ -185,6 +186,7 @@ options = [
"Demo", "Demo",
"Analog", "Analog",
"Network - Dante", "Network - Dante",
"TextCast",
] ]
saved_audio_mode = saved_settings.get("audio_mode", "Demo") saved_audio_mode = saved_settings.get("audio_mode", "Demo")
if saved_audio_mode not in options: if saved_audio_mode not in options:
@@ -196,7 +198,7 @@ audio_mode = st.selectbox(
"Audio Mode", "Audio Mode",
options, options,
index=options.index(saved_audio_mode) if saved_audio_mode in options else options.index("Demo"), index=options.index(saved_audio_mode) if saved_audio_mode in options else options.index("Demo"),
disabled=is_streaming, disabled=is_streaming or textcast_is_streaming,
help=( help=(
"Select the audio input source. Choose 'USB' for a connected USB audio device (via PipeWire), " "Select the audio input source. Choose 'USB' for a connected USB audio device (via PipeWire), "
"'Network' (AES67) for network RTP/AES67 sources, " "'Network' (AES67) for network RTP/AES67 sources, "
@@ -226,11 +228,44 @@ else:
running_mode = backend_mode_mapped if (is_streaming and backend_mode_mapped) else audio_mode running_mode = backend_mode_mapped if (is_streaming and backend_mode_mapped) else audio_mode
# Start/Stop buttons and status (moved to top) # Start/Stop buttons and status (moved to top)
if audio_mode == "Demo": if audio_mode == "TextCast":
start_stream, stop_stream = render_stream_controls(textcast_is_streaming, "Start TextCast", "Stop TextCast", "TextCast", False)
elif audio_mode == "Demo":
start_stream, stop_stream = render_stream_controls(is_streaming, "Start Demo", "Stop Demo", running_mode, secondary_is_streaming) start_stream, stop_stream = render_stream_controls(is_streaming, "Start Demo", "Stop Demo", running_mode, secondary_is_streaming)
else: else:
start_stream, stop_stream = render_stream_controls(is_streaming, "Start Auracast", "Stop Auracast", running_mode, secondary_is_streaming) start_stream, stop_stream = render_stream_controls(is_streaming, "Start Auracast", "Stop Auracast", running_mode, secondary_is_streaming)
# TextCast: DCP XML file uploader
if audio_mode == "TextCast":
st.markdown("#### DCP Subtitle File")
dcp_file = st.file_uploader(
"Upload DCP XML subtitle file (.xml)",
type=["xml"],
disabled=textcast_is_streaming,
help="Upload a DCP-compliant subtitle XML file. Subtitles will be broadcast over Auracast.",
)
if dcp_file is not None:
content = dcp_file.read().decode("utf-8", errors="replace")
st.session_state['_textcast_dcp_content'] = content
st.session_state['_textcast_dcp_name'] = dcp_file.name
st.success(f"Loaded: {dcp_file.name} ({len(content):,} bytes)")
elif st.session_state.get('_textcast_dcp_name'):
st.info(f"Using previously uploaded file: {st.session_state['_textcast_dcp_name']}")
else:
st.warning("No subtitle file loaded. Upload a DCP XML file or use the sample below.")
if st.button("Load sample subtitle file", disabled=textcast_is_streaming):
import os as _os
_sample = _os.path.abspath(_os.path.join(
_os.path.dirname(__file__), '..', 'testdata', 'sample_subtitles.xml'))
try:
with open(_sample, 'r', encoding='utf-8') as _f:
_content = _f.read()
st.session_state['_textcast_dcp_content'] = _content
st.session_state['_textcast_dcp_name'] = 'sample_subtitles.xml'
st.rerun()
except Exception as _e:
st.error(f"Could not load sample: {_e}")
# Analog gain control (only for Analog mode, placed below start button) # Analog gain control (only for Analog mode, placed below start button)
analog_gain_db_left = 0 # default (dB) analog_gain_db_left = 0 # default (dB)
analog_gain_db_right = 0 # default (dB) analog_gain_db_right = 0 # default (dB)
@@ -1793,22 +1828,46 @@ else:
if stop_stream: if stop_stream:
st.session_state['stream_started'] = False st.session_state['stream_started'] = False
try: try:
r = requests.post(f"{BACKEND_URL}/stop_audio").json() if audio_mode == "TextCast":
if audio_mode == "Demo": r = requests.post(f"{BACKEND_URL}/stop_textcast").json()
st.session_state['demo_stream_started'] = False else:
if r['was_running']: r = requests.post(f"{BACKEND_URL}/stop_audio").json()
if audio_mode == "Demo":
st.session_state['demo_stream_started'] = False
if r.get('was_running'):
is_stopped = True is_stopped = True
except Exception as e: except Exception as e:
st.error(f"Error: {e}") st.error(f"Error: {e}")
if start_stream: if start_stream:
# Always send stop to ensure backend is in a clean state, regardless of current status if audio_mode == "TextCast":
r = requests.post(f"{BACKEND_URL}/stop_audio").json() uploaded = st.session_state.get('_textcast_dcp_content')
# Small pause lets backend fully release audio devices before re-init if not uploaded:
time.sleep(1) st.error("Upload a DCP XML file first.")
else:
try:
ru = requests.post(f"{BACKEND_URL}/upload_dcp", json={"xml": uploaded})
if not ru.ok:
st.error(f"Upload failed: {ru.text}")
else:
rs = requests.post(f"{BACKEND_URL}/start_textcast")
if rs.ok:
st.success("TextCast started.")
st.rerun()
else:
st.error(f"Start failed: {rs.text}")
except Exception as e:
st.error(f"Error: {e}")
if audio_mode == "Demo": else:
# Always send stop to ensure backend is in a clean state, regardless of current status
r = requests.post(f"{BACKEND_URL}/stop_audio").json()
# Small pause lets backend fully release audio devices before re-init
time.sleep(1)
if audio_mode == "Demo":
demo_cfg = demo_stream_map[demo_selected] demo_cfg = demo_stream_map[demo_selected]
q = QUALITY_MAP[demo_cfg['quality']] q = QUALITY_MAP[demo_cfg['quality']]
+82
View File
@@ -209,6 +209,10 @@ multicaster1: multicast_control.Multicaster | None = None
multicaster2: multicast_control.Multicaster | None = None multicaster2: multicast_control.Multicaster | None = None
_stream_lock = asyncio.Lock() # serialize initialize/stop_audio on API side _stream_lock = asyncio.Lock() # serialize initialize/stop_audio on API side
# TextCast state
_textcast_task: asyncio.Task | None = None
DCP_UPLOAD_PATH = os.path.join(os.path.dirname(__file__), 'uploaded_subtitles.xml')
# BLE / audio event loop set in __main__ before uvicorn starts. # BLE / audio event loop set in __main__ before uvicorn starts.
# All coroutines that touch Bumble objects or the audio pipeline MUST run # All coroutines that touch Bumble objects or the audio pipeline MUST run
# on this loop. HTTP handlers call _on_ble_loop() to cross into it. # on this loop. HTTP handlers call _on_ble_loop() to cross into it.
@@ -705,6 +709,81 @@ async def _stop_audio_impl():
log.error("Exception in /stop_audio: %s", traceback.format_exc()) log.error("Exception in /stop_audio: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.post("/upload_dcp")
async def upload_dcp(payload: dict):
"""Save DCP XML content for TextCast. Body: {"xml": "<DCSubtitle>..."}"""
xml_content = payload.get("xml", "")
if not xml_content.strip():
raise HTTPException(status_code=400, detail="Empty XML content")
try:
with open(DCP_UPLOAD_PATH, 'w', encoding='utf-8') as f:
f.write(xml_content)
log.info("DCP XML saved to %s (%d bytes)", DCP_UPLOAD_PATH, len(xml_content))
return {"status": "ok", "path": DCP_UPLOAD_PATH}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/start_textcast")
async def start_textcast():
"""Start text-over-Auracast broadcast using the uploaded DCP XML file."""
return await _on_ble_loop(_start_textcast_impl())
async def _start_textcast_impl():
global _textcast_task
if not os.path.exists(DCP_UPLOAD_PATH):
raise HTTPException(status_code=400, detail="No DCP file uploaded. Use /upload_dcp first.")
# Stop any running audio/textcast first
await _stop_all()
await _stop_textcast_impl()
from auracast.text_multicast import broadcast_text
_textcast_task = asyncio.get_event_loop().create_task(
broadcast_text(DCP_UPLOAD_PATH, TRANSPORT1)
)
settings = {
'is_streaming': True,
'audio_mode': 'TextCast',
'textcast_is_streaming': True,
'timestamp': datetime.utcnow().isoformat(),
}
save_stream_settings(settings)
_led_on()
log.info("TextCast started (DCP: %s)", DCP_UPLOAD_PATH)
return {"status": "started"}
@app.post("/stop_textcast")
async def stop_textcast():
"""Stop an active TextCast broadcast."""
return await _on_ble_loop(_stop_textcast_impl())
async def _stop_textcast_impl():
global _textcast_task
was_running = False
if _textcast_task is not None and not _textcast_task.done():
was_running = True
_textcast_task.cancel()
try:
await asyncio.wait_for(asyncio.shield(_textcast_task), timeout=3.0)
except (asyncio.CancelledError, asyncio.TimeoutError, Exception):
pass
_textcast_task = None
_led_off()
settings = load_stream_settings() or {}
if settings.get('audio_mode') == 'TextCast':
settings['is_streaming'] = False
settings['textcast_is_streaming'] = False
settings['timestamp'] = datetime.utcnow().isoformat()
save_stream_settings(settings)
log.info("TextCast stopped")
return {"status": "stopped", "was_running": was_running}
@app.post("/adc_gain") @app.post("/adc_gain")
async def set_adc_gain(payload: dict): async def set_adc_gain(payload: dict):
"""Set ADC gain in dB for left and right channels without restarting the stream. """Set ADC gain in dB for left and right channels without restarting the stream.
@@ -763,6 +842,9 @@ async def get_status():
status["secondary"] = secondary status["secondary"] = secondary
status["secondary_is_streaming"] = bool(secondary.get("is_streaming", False)) status["secondary_is_streaming"] = bool(secondary.get("is_streaming", False))
status["led_enabled"] = _LED_ENABLED status["led_enabled"] = _LED_ENABLED
status["textcast_is_streaming"] = (
_textcast_task is not None and not _textcast_task.done()
)
return status return status
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<DCSubtitle Version="1.0">
<SubtitleID>a1b2c3d4-e5f6-7890-abcd-ef1234567890</SubtitleID>
<MovieTitle>Sample TextCast Subtitles</MovieTitle>
<ReelNumber>1</ReelNumber>
<Language>en</Language>
<LoadFont Id="Font1" URI="Arial.ttf"/>
<Font Id="Font1" Color="FFFFFFFF" Effect="none" Size="42" Italic="no">
<Subtitle SpotNumber="1" TimeIn="00:00:02:00" TimeOut="00:00:05:00" FadeUpTime="0" FadeDownTime="0">
<Text HAlign="center" VAlign="bottom">Welcome to TextCast.</Text>
</Subtitle>
<Subtitle SpotNumber="2" TimeIn="00:00:06:00" TimeOut="00:00:09:00" FadeUpTime="0" FadeDownTime="0">
<Text HAlign="center" VAlign="bottom">Text transmitted over Auracast BLE.</Text>
</Subtitle>
<Subtitle SpotNumber="3" TimeIn="00:00:10:00" TimeOut="00:00:13:00" FadeUpTime="0" FadeDownTime="0">
<Text HAlign="center" VAlign="bottom">No LC3 audio codec involved.</Text>
</Subtitle>
<Subtitle SpotNumber="4" TimeIn="00:00:14:00" TimeOut="00:00:17:00" FadeUpTime="0" FadeDownTime="0">
<Text HAlign="center" VAlign="bottom">Raw ISO SDUs carry UTF-8 text.</Text>
</Subtitle>
<Subtitle SpotNumber="5" TimeIn="00:00:18:00" TimeOut="00:00:21:00" FadeUpTime="0" FadeDownTime="0">
<Text HAlign="center" VAlign="bottom">100 frames per second at 40 bytes.</Text>
</Subtitle>
<Subtitle SpotNumber="6" TimeIn="00:00:22:00" TimeOut="00:00:25:00" FadeUpTime="0" FadeDownTime="0">
<Text HAlign="center" VAlign="bottom">Scrolling display on SH1106 OLED.</Text>
</Subtitle>
<Subtitle SpotNumber="7" TimeIn="00:00:26:00" TimeOut="00:00:29:00" FadeUpTime="0" FadeDownTime="0">
<Text HAlign="center" VAlign="bottom">Each new line scrolls up the screen.</Text>
</Subtitle>
<Subtitle SpotNumber="8" TimeIn="00:00:30:00" TimeOut="00:00:33:00" FadeUpTime="0" FadeDownTime="0">
<Text HAlign="center" VAlign="bottom">The quick brown fox jumps over</Text>
</Subtitle>
<Subtitle SpotNumber="9" TimeIn="00:00:34:00" TimeOut="00:00:37:00" FadeUpTime="0" FadeDownTime="0">
<Text HAlign="center" VAlign="bottom">the lazy dog.</Text>
</Subtitle>
<Subtitle SpotNumber="10" TimeIn="00:00:38:00" TimeOut="00:00:41:00" FadeUpTime="0" FadeDownTime="0">
<Text HAlign="center" VAlign="bottom">Speech-to-text output goes here.</Text>
</Subtitle>
<Subtitle SpotNumber="11" TimeIn="00:00:42:00" TimeOut="00:00:45:00" FadeUpTime="0" FadeDownTime="0">
<Text HAlign="center" VAlign="bottom">Latency is dominated by BLE BIG.</Text>
</Subtitle>
<Subtitle SpotNumber="12" TimeIn="00:00:46:00" TimeOut="00:00:49:00" FadeUpTime="0" FadeDownTime="0">
<Text HAlign="center" VAlign="bottom">Typical end-to-end: under 50 ms.</Text>
</Subtitle>
<Subtitle SpotNumber="13" TimeIn="00:00:50:00" TimeOut="00:00:53:00" FadeUpTime="0" FadeDownTime="0">
<Text HAlign="center" VAlign="bottom">One transmitter, many receivers.</Text>
</Subtitle>
<Subtitle SpotNumber="14" TimeIn="00:00:54:00" TimeOut="00:00:57:00" FadeUpTime="0" FadeDownTime="0">
<Text HAlign="center" VAlign="bottom">Built on Bumble and Zephyr RTOS.</Text>
</Subtitle>
<Subtitle SpotNumber="15" TimeIn="00:00:58:00" TimeOut="00:01:01:00" FadeUpTime="0" FadeDownTime="0">
<Text HAlign="center" VAlign="bottom">End of demonstration. Thank you.</Text>
</Subtitle>
</Font>
</DCSubtitle>
+155
View File
@@ -0,0 +1,155 @@
"""Text-over-Auracast transmitter.
Reads a DCP XML subtitle file and broadcasts each subtitle as raw ISO SDUs.
No LC3 encoding is used. The BIG is advertised with codec_id=LC3 (required
for BAP sync) but the SDU payload is plain UTF-8 text with a magic header.
Frame format (SDU_SIZE bytes total):
Byte 0 : TEXT_MAGIC (0xAA) identifies this as a text SDU
Byte 1 : text length N 0 means idle/clear
Bytes 2..N+1: UTF-8 text
Bytes N+2.. : zero padding to SDU_SIZE
Usage:
poetry run python -m auracast.text_multicast \\
--dcp ./auracast/testdata/sample_subtitles.xml \\
--transport serial:/dev/ttyAMA3,1000000,rtscts
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
from auracast import auracast_config, multicast
from auracast.dcp_parser import parse_dcp_xml
TEXT_MAGIC = 0xAA
SDU_SIZE = 64 # octets_per_frame; 62 usable text bytes per frame
SDU_INTERVAL_US = 10_000 # 10 ms → 100 SDUs/sec
BROADCAST_NAME = 'TextCast'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(name)s: %(message)s',
)
log = logging.getLogger('text_multicast')
def _make_text_frame(text: str) -> bytes:
"""Encode a subtitle string into a fixed-size TEXT SDU."""
text_bytes = text.encode('utf-8')[: SDU_SIZE - 2]
frame = bytes([TEXT_MAGIC, len(text_bytes)]) + text_bytes
return frame + bytes(SDU_SIZE - len(frame))
def _make_idle_frame() -> bytes:
"""Return an idle frame (magic=0, signals 'no active subtitle')."""
return bytes(SDU_SIZE)
async def _text_stream(bigs: dict, subtitles: list, loop: bool = True) -> None:
"""Main text streaming loop.
Writes one SDU every ~10 ms (flow-controlled by the BLE controller).
Subtitle timing is derived from the frame counter: frame N ≈ N × 10 ms.
When *loop* is True (default) the subtitle list repeats indefinitely.
"""
iso_queue = bigs['big0']['iso_queue']
frame_interval_s = SDU_INTERVAL_US / 1_000_000
frame_count = 0
sub_idx = 0
n = len(subtitles)
last_log_sub = -1
loop_count = 0
# Total duration of one pass: end of last subtitle + 2 s gap before restart
_loop_gap_s = 2.0
_pass_duration_s = subtitles[-1].time_out + _loop_gap_s if n > 0 else 0.0
log.info("Streaming %d subtitle(s) (loop=%s). Press Ctrl-C to stop.", n, loop)
while True:
now_s = frame_count * frame_interval_s
# Advance past subtitles whose time_out has passed
while sub_idx < n and now_s >= subtitles[sub_idx].time_out:
sub_idx += 1
# Determine what to send
if sub_idx < n and now_s >= subtitles[sub_idx].time_in:
frame = _make_text_frame(subtitles[sub_idx].text)
if sub_idx != last_log_sub:
log.info("[loop %d %05.1fs] %s", loop_count, now_s, subtitles[sub_idx].text)
last_log_sub = sub_idx
else:
frame = _make_idle_frame()
await iso_queue.write(frame)
frame_count += 1
# End of pass
if n > 0 and now_s >= _pass_duration_s:
if loop:
loop_count += 1
log.info("Loop %d complete restarting.", loop_count)
frame_count = 0
sub_idx = 0
last_log_sub = -1
else:
log.info("All subtitles transmitted. Exiting.")
break
async def broadcast_text(dcp_path: str, transport: str, loop: bool = True) -> None:
subtitles = parse_dcp_xml(dcp_path)
if not subtitles:
log.error("No subtitles found in %s", dcp_path)
return
log.info("Loaded %d subtitle(s) from %s", len(subtitles), dcp_path)
config = auracast_config.AuracastConfigGroup(
bigs=[
auracast_config.AuracastBigConfig(
name=BROADCAST_NAME,
program_info='Text Broadcast',
language='eng',
audio_source='file:dummy', # not used streamer loop is replaced
iso_que_len=4,
),
],
auracast_sampling_rate_hz=16000,
octets_per_frame=SDU_SIZE,
frame_duration_us=SDU_INTERVAL_US,
presentation_delay_us=40_000,
qos_config=auracast_config.AuracastQosRobust(),
transport=transport,
)
async with multicast.create_device(config) as device:
bigs = await multicast.init_broadcast(device, config, config.bigs)
await _text_stream(bigs, subtitles, loop=loop)
def main() -> None:
parser = argparse.ArgumentParser(description='Auracast text (subtitle) transmitter')
parser.add_argument('--dcp', required=True, help='Path to DCP XML subtitle file')
parser.add_argument(
'--transport',
default=os.environ.get(
'AURACAST_TRANSPORT',
'serial:/dev/ttyAMA3,1000000,rtscts',
),
help='Bumble HCI transport string (default: $AURACAST_TRANSPORT or ttyAMA3)',
)
parser.add_argument(
'--no-loop',
action='store_true',
help='Play subtitles once and exit instead of looping indefinitely',
)
args = parser.parse_args()
multicast.run_async(broadcast_text(args.dcp, args.transport, loop=not args.no_loop))
if __name__ == '__main__':
main()