Textcast works.
This commit is contained in:
@@ -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)
|
||||||
@@ -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']]
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user