diff --git a/src/auracast/dcp_parser.py b/src/auracast/dcp_parser.py new file mode 100644 index 0000000..f7a0d5e --- /dev/null +++ b/src/auracast/dcp_parser.py @@ -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) diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index dd70a8b..607b870 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -141,6 +141,7 @@ except Exception: # Define is_streaming early from the fetched status for use throughout the UI 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. secondary_status = saved_settings.get("secondary") or {} @@ -185,6 +186,7 @@ options = [ "Demo", "Analog", "Network - Dante", + "TextCast", ] saved_audio_mode = saved_settings.get("audio_mode", "Demo") if saved_audio_mode not in options: @@ -196,7 +198,7 @@ audio_mode = st.selectbox( "Audio Mode", options, 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=( "Select the audio input source. Choose 'USB' for a connected USB audio device (via PipeWire), " "'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 # 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) else: 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_db_left = 0 # default (dB) analog_gain_db_right = 0 # default (dB) @@ -1793,22 +1828,46 @@ else: if stop_stream: st.session_state['stream_started'] = False try: - r = requests.post(f"{BACKEND_URL}/stop_audio").json() - if audio_mode == "Demo": - st.session_state['demo_stream_started'] = False - if r['was_running']: + if audio_mode == "TextCast": + r = requests.post(f"{BACKEND_URL}/stop_textcast").json() + else: + 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 except Exception as e: st.error(f"Error: {e}") if start_stream: - # 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 == "TextCast": + uploaded = st.session_state.get('_textcast_dcp_content') + if not uploaded: + 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] q = QUALITY_MAP[demo_cfg['quality']] diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index 71f6034..b4d4b72 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -209,6 +209,10 @@ multicaster1: multicast_control.Multicaster | None = None multicaster2: multicast_control.Multicaster | None = None _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. # 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. @@ -705,6 +709,81 @@ async def _stop_audio_impl(): log.error("Exception in /stop_audio: %s", traceback.format_exc()) 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": "..."}""" + 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") async def set_adc_gain(payload: dict): """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_is_streaming"] = bool(secondary.get("is_streaming", False)) status["led_enabled"] = _LED_ENABLED + status["textcast_is_streaming"] = ( + _textcast_task is not None and not _textcast_task.done() + ) return status diff --git a/src/auracast/testdata/sample_subtitles.xml b/src/auracast/testdata/sample_subtitles.xml new file mode 100644 index 0000000..4078409 --- /dev/null +++ b/src/auracast/testdata/sample_subtitles.xml @@ -0,0 +1,71 @@ + + + a1b2c3d4-e5f6-7890-abcd-ef1234567890 + Sample TextCast Subtitles + 1 + en + + + + + Welcome to TextCast. + + + + Text transmitted over Auracast BLE. + + + + No LC3 audio codec involved. + + + + Raw ISO SDUs carry UTF-8 text. + + + + 100 frames per second at 40 bytes. + + + + Scrolling display on SH1106 OLED. + + + + Each new line scrolls up the screen. + + + + The quick brown fox jumps over + + + + the lazy dog. + + + + Speech-to-text output goes here. + + + + Latency is dominated by BLE BIG. + + + + Typical end-to-end: under 50 ms. + + + + One transmitter, many receivers. + + + + Built on Bumble and Zephyr RTOS. + + + + End of demonstration. Thank you. + + + + diff --git a/src/auracast/text_multicast.py b/src/auracast/text_multicast.py new file mode 100644 index 0000000..1992872 --- /dev/null +++ b/src/auracast/text_multicast.py @@ -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()