10 Commits

Author SHA1 Message Date
pober 53fd074d22 STT experiments, implements both vosx and whisper; whisper is too slow (cpu heavy) vosx has low acc; bigger vosx needs too much storage. 2026-05-28 10:12:51 +02:00
pober d1471fae79 Textcast works. 2026-05-27 14:27:29 +02:00
pober 50761a4b37 bugfix/1025-local-link-lost-connection (#34)
Fixes the bug that local link loses connection after a few minutes.

Openproject:
#1025
#608

Reviewed-on: #34
2026-05-20 10:12:08 +00:00
pober 5bb31e3f6a bugfix/1087-UI-reset-refresh (#33)
Openproject:
#1087

Reviewed-on: #33
2026-05-20 10:01:13 +00:00
pober edd23fc115 feature/1040-dante-activation (#31)
Makes the activation of dante possible.
Fixes issues with local link for DANTE.
First implementation of audiopipeline in its own thread.
Prevents Frontend from interrupting the audio stream.

Openproject:
#1040
#1069
#652
#1041
#1063

Reviewed-on: #31
2026-05-20 09:54:35 +00:00
pober 19a01e404c Removes manufacturer data. (#30)
Reviewed-on: #30
2026-05-20 09:40:22 +00:00
pstruebi efb55050c0 feature/1khz_testtone (#27)
- 1kHz test tone added
- all audio file converted to lc3 to save space
- streaming loop for lc3 files fixed

@pober

Reviewed-on: #27
Co-authored-by: pstruebi <struebin.patrick@gmail.com>
Co-committed-by: pstruebi <struebin.patrick@gmail.com>
2026-05-19 13:21:48 +00:00
pstruebi c82a17016e implement changes for dynamic power setting (#28)
Implements power control for the radios, both radios independent.

Reviewed-on: #28
Co-authored-by: pstruebi <struebin.patrick@gmail.com>
Co-committed-by: pstruebi <struebin.patrick@gmail.com>
2026-05-19 12:33:50 +00:00
pober 6d54e72f1d Add new reset mechanism with sleep. 2026-04-10 12:22:18 +02:00
pober df6c85d9ff Add new reset mechanism 2. 2026-04-10 11:58:16 +02:00
85 changed files with 13493 additions and 11515 deletions
+6
View File
@@ -53,3 +53,9 @@ src/scripts/temperature_log*
src/auracast/server/recordings/
src/auracast/server/led_settings.json
# Dante license files
*.lic
src/dep/dante_package/dante_data/activation/device.lic
src/dep/dante_package/dante_data/activation/manufacturer.cert
+3 -1
View File
@@ -19,7 +19,9 @@ dependencies = [
"smbus2 (>=0.5.0,<0.6.0)",
"samplerate (>=0.2.2,<0.3.0)",
"rpi-gpio (>=0.7.1,<0.8.0)",
"pyalsaaudio @ git+ssh://git@gitea.summitwave.work:222/auracaster/sw_pyalsaaudio.git@b3d11582e03df6929b2e7acbaa1306afc7b8a6bc"
"pyalsaaudio @ git+ssh://git@gitea.summitwave.work:222/auracaster/sw_pyalsaaudio.git@b3d11582e03df6929b2e7acbaa1306afc7b8a6bc",
"vosk (>=0.3.45)",
"faster-whisper (>=1.0.0)"
]
[project.optional-dependencies]
+26 -9
View File
@@ -1,5 +1,11 @@
from typing import List
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
# Discrete TX power levels (dBm) supported by the Nordic SoftDevice Controller
# for the nRF radio PA. The HCI controller will clamp requested values to the
# nearest supported step. The maximum is bounded by CONFIG_BT_CTLR_TX_PWR_*
# in the hci_uart firmware (currently +8 dBm).
TX_POWER_VALID = [8, 7, 6, 5, 4, 3, 2, 0, -4, -8, -12, -16, -20]
# Define some base to hold the relevant parameters
class AuracastQoSConfig(BaseModel):
@@ -28,13 +34,24 @@ class AuracastGlobalConfig(BaseModel):
octets_per_frame: int = 40 #48kbps@24kHz # bitrate = octets_per_frame * 8 / frame len
frame_duration_us: int = 10000
presentation_delay_us: int = 40000
# TODO:pydantic does not support bytes serialization - use .hex and np.fromhex()
manufacturer_data: tuple[int, bytes] | tuple[None, None] = (None, None)
# LE Audio: Broadcast Audio Immediate Rendering (metadata type 0x09)
# When true, include a zero-length LTV with type 0x09 in the subgroup metadata
# so receivers may render earlier than the presentation delay for lower latency.
immediate_rendering: bool = False
assisted_listening_stream: bool = False
# Bluetooth advertising TX power for this radio in dBm (per advertising set).
# Sent through HCI_LE_Set_Extended_Advertising_Parameters; the SDC clamps to
# nearest supported hardware step and propagates to primary/secondary adv,
# the periodic advertising train and the BIS ISO PDUs.
advertising_tx_power: int = 8
@field_validator('advertising_tx_power')
@classmethod
def _snap_tx_power(cls, v: int) -> int:
# Snap to the nearest supported discrete step in TX_POWER_VALID.
if v in TX_POWER_VALID:
return v
return min(TX_POWER_VALID, key=lambda s: abs(s - v))
# "Audio input. "
# "'device' -> use the host's default sound input device, "
@@ -62,7 +79,7 @@ class AuracastBigConfigDeu(AuracastBigConfig):
name: str = 'Hörsaal A'
language: str ='deu'
program_info: str = 'Vorlesung DE'
audio_source: str = 'file:./testdata/wave_particle_5min_de.wav'
audio_source: str = 'file:./testdata/wave_particle_5min_de.lc3'
class AuracastBigConfigEng(AuracastBigConfig):
id: int = 123
@@ -70,7 +87,7 @@ class AuracastBigConfigEng(AuracastBigConfig):
name: str = 'Lecture Hall A'
language: str ='eng'
program_info: str = 'Lecture EN'
audio_source: str = 'file:./testdata/wave_particle_5min_en.wav'
audio_source: str = 'file:./testdata/wave_particle_5min_en.lc3'
class AuracastBigConfigFra(AuracastBigConfig):
id: int = 1234
@@ -79,7 +96,7 @@ class AuracastBigConfigFra(AuracastBigConfig):
name: str = 'Auditoire A'
language: str ='fra'
program_info: str = 'Auditoire FR'
audio_source: str = 'file:./testdata/wave_particle_5min_fr.wav'
audio_source: str = 'file:./testdata/wave_particle_5min_fr.lc3'
class AuracastBigConfigSpa(AuracastBigConfig):
id: int =12345
@@ -87,7 +104,7 @@ class AuracastBigConfigSpa(AuracastBigConfig):
name: str = 'Auditorio A'
language: str ='spa'
program_info: str = 'Auditorio ES'
audio_source: str = 'file:./testdata/wave_particle_5min_es.wav'
audio_source: str = 'file:./testdata/wave_particle_5min_es.lc3'
class AuracastBigConfigIta(AuracastBigConfig):
id: int =1234567
@@ -95,7 +112,7 @@ class AuracastBigConfigIta(AuracastBigConfig):
name: str = 'Aula A'
language: str ='ita'
program_info: str = 'Aula IT'
audio_source: str = 'file:./testdata/wave_particle_5min_it.wav'
audio_source: str = 'file:./testdata/wave_particle_5min_it.lc3'
class AuracastBigConfigPol(AuracastBigConfig):
@@ -104,7 +121,7 @@ class AuracastBigConfigPol(AuracastBigConfig):
name: str = 'Sala Wykładowa'
language: str ='pol'
program_info: str = 'Sala Wykładowa PL'
audio_source: str = 'file:./testdata/wave_particle_5min_pl.wav'
audio_source: str = 'file:./testdata/wave_particle_5min_pl.lc3'
class AuracastConfigGroup(AuracastGlobalConfig):
+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)
+259
View File
@@ -0,0 +1,259 @@
"""faster-whisper speech-to-text → TextCast streamer.
Captures mono audio from an analog ALSA/sounddevice input, runs
faster-whisper offline ASR in a background thread (chunked, every
CHUNK_S seconds), and broadcasts recognised text over the TextCast BLE
broadcast using the same SDU framing as text_multicast.py.
Usage (CLI):
poetry run python -m auracast.faster_whisper_textcast \\
--model tiny.en \\
--device ch1 \\
--transport serial:/dev/ttyAMA3,1000000,rtscts
"""
from __future__ import annotations
import asyncio
import logging
import os
import queue
import threading
import time
from typing import Optional
import numpy as np
import samplerate
import sounddevice as sd
from auracast import auracast_config, multicast
from auracast.text_multicast import (
SDU_SIZE,
SDU_INTERVAL_US,
_make_text_frame,
_make_idle_frame,
)
log = logging.getLogger('faster_whisper_textcast')
CAPTURE_SAMPLE_RATE = 48_000
WHISPER_SAMPLE_RATE = 16_000
BLOCK_FRAMES_48K = 4800 # 100 ms capture blocks
CHUNK_S = 3.0 # transcribe every N seconds of audio
CAPTION_HOLD_S = 4.0 # keep caption visible after last transcription
SILENCE_RMS = 0.003 # skip transcription if chunk is below this RMS
BROADCAST_NAME = 'LiveCaption'
VALID_MODELS = ['tiny.en', 'base.en', 'small.en', 'tiny', 'base', 'small']
def _tail_to_fit(text: str, max_bytes: int) -> str:
"""Return the tail of *text* that fits in *max_bytes* UTF-8 bytes."""
encoded = text.encode('utf-8')
if len(encoded) <= max_bytes:
return text
tail = encoded[-max_bytes:].decode('utf-8', errors='ignore')
sp = tail.find(' ')
return tail[sp + 1:] if sp != -1 else tail
def _resolve_device(device: str) -> Optional[int]:
"""Return sounddevice index for a name or numeric string, or None for default."""
if not device:
return None
if device.isdigit():
return int(device)
for i, d in enumerate(sd.query_devices()):
if d['name'] == device and d['max_input_channels'] > 0:
return i
log.warning("Device '%s' not found in sounddevice list using default input", device)
return None
async def _iso_write_loop(bigs: dict, shared: dict, lock: threading.Lock) -> None:
"""ISO SDU write loop runs at ~10 ms per iteration."""
iso_queue = bigs['big0']['iso_queue']
last_sent: str = ''
while True:
now = time.monotonic()
with lock:
text: str = shared.get('text', '')
expiry: float = shared.get('expiry', 0.0)
if text and now < expiry:
display_text = _tail_to_fit(text, SDU_SIZE - 2)
if display_text != last_sent:
log.info("Caption: %s", display_text)
last_sent = display_text
frame = _make_text_frame(display_text)
else:
if last_sent:
log.info("Caption cleared")
last_sent = ''
with lock:
shared['text'] = ''
frame = _make_idle_frame()
await iso_queue.write(frame)
def _whisper_thread(
model_size: str,
device: str,
shared: dict,
lock: threading.Lock,
stop_event: threading.Event,
) -> None:
"""Blocking audio capture + faster-whisper transcription loop."""
try:
from faster_whisper import WhisperModel # type: ignore
except ImportError:
log.error("faster-whisper is not installed. Run: poetry add faster-whisper")
return
log.info("Loading faster-whisper model '%s' (int8, CPU) …", model_size)
model = WhisperModel(model_size, device="cpu", compute_type="int8")
log.info("Model '%s' loaded.", model_size)
audio_q: queue.Queue = queue.Queue()
resampler = samplerate.Resampler('sinc_fastest', channels=1)
ratio = WHISPER_SAMPLE_RATE / CAPTURE_SAMPLE_RATE
chunk_frames = int(CHUNK_S * WHISPER_SAMPLE_RATE)
audio_buffer = np.zeros(0, dtype=np.float32)
dev_idx = _resolve_device(device)
def _cb(indata: np.ndarray, frames: int, time_info, status) -> None:
if status:
log.warning("Audio status: %s", status)
if stop_event.is_set():
raise sd.CallbackStop()
mono = indata[:, 0].astype(np.float32)
downsampled = resampler.process(mono, ratio, end_of_input=False)
audio_q.put(downsampled.copy())
try:
with sd.InputStream(
samplerate=CAPTURE_SAMPLE_RATE,
blocksize=BLOCK_FRAMES_48K,
device=dev_idx,
dtype='float32',
channels=1,
callback=_cb,
):
log.info("WhisperCast listening on device '%s' (idx=%s) …", device, dev_idx)
while not stop_event.is_set():
try:
chunk = audio_q.get(timeout=0.2)
audio_buffer = np.concatenate([audio_buffer, chunk])
except queue.Empty:
continue
if len(audio_buffer) < chunk_frames:
continue
pcm = audio_buffer[:chunk_frames].copy()
audio_buffer = audio_buffer[chunk_frames:]
rms = float(np.sqrt(np.mean(pcm ** 2)))
if rms < SILENCE_RMS:
continue
t0 = time.monotonic()
segments, _ = model.transcribe(
pcm,
beam_size=1,
language="en",
vad_filter=True,
vad_parameters={"min_silence_duration_ms": 300},
)
text = ' '.join(s.text.strip() for s in segments).strip()
elapsed = time.monotonic() - t0
if text:
log.info("Transcribed (%.2fs): %s", elapsed, text)
with lock:
shared['text'] = text
shared['expiry'] = time.monotonic() + CAPTION_HOLD_S
else:
log.debug("Silent chunk skipped (rms=%.4f, took=%.2fs)", rms, elapsed)
except Exception as exc:
log.error("WhisperCast thread error: %s", exc, exc_info=True)
async def broadcast_whisper(
transport: str,
model_size: str = 'tiny.en',
device: str = 'ch1',
) -> None:
"""Start a faster-whisper → TextCast broadcast. Runs until cancelled."""
if model_size not in VALID_MODELS:
raise ValueError(f"Unknown model '{model_size}'. Valid: {VALID_MODELS}")
config = auracast_config.AuracastConfigGroup(
bigs=[
auracast_config.AuracastBigConfig(
name=BROADCAST_NAME,
program_info='Live Captions',
language='eng',
audio_source='file:dummy',
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,
)
shared: dict = {'text': '', 'expiry': 0.0}
lock = threading.Lock()
stop_event = threading.Event()
async with multicast.create_device(config) as ble_device:
bigs = await multicast.init_broadcast(ble_device, config, config.bigs)
t = threading.Thread(
target=_whisper_thread,
args=(model_size, device, shared, lock, stop_event),
daemon=True,
)
t.start()
log.info("WhisperCast started (device=%s, model=%s)", device, model_size)
try:
await _iso_write_loop(bigs, shared, lock)
except asyncio.CancelledError:
log.info("WhisperCast cancelled shutting down")
stop_event.set()
t.join(timeout=5.0)
raise
def main() -> None:
global CHUNK_S
import argparse
parser = argparse.ArgumentParser(description='faster-whisper → Auracast TextCast')
parser.add_argument(
'--model', default='tiny.en', choices=VALID_MODELS,
help='Whisper model size (default: tiny.en)',
)
parser.add_argument('--device', default='ch1',
help='sounddevice input name or index (default: ch1)')
parser.add_argument(
'--transport',
default=os.environ.get('AURACAST_TRANSPORT', 'serial:/dev/ttyAMA3,1000000,rtscts'),
help='Bumble HCI transport string',
)
parser.add_argument('--chunk', type=float, default=CHUNK_S,
help=f'Seconds per transcription chunk (default: {CHUNK_S})')
args = parser.parse_args()
CHUNK_S = args.chunk
multicast.run_async(broadcast_whisper(args.transport, args.model, args.device))
if __name__ == '__main__':
main()
+87 -26
View File
@@ -49,6 +49,7 @@ import bumble.transport
import bumble.utils
from bumble.device import Host, AdvertisingChannelMap
from bumble.audio import io as audio_io
from bumble.vendor.zephyr.hci import HCI_Write_Tx_Power_Level_Command
from auracast import auracast_config
from auracast.utils.read_lc3_file import read_lc3_file
@@ -206,7 +207,7 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
length, data = self._pcm.read_sw(frame_size + self._bang_bang)
avail = self._pcm.avail()
SETPOINT = 120
TOLERANCE = 40
TOLERANCE = 80
if avail < SETPOINT - TOLERANCE:
self._bang_bang = -1
elif avail > SETPOINT + TOLERANCE:
@@ -462,21 +463,6 @@ async def init_broadcast(
],
)
logger.info('Setup Advertising')
advertising_manufacturer_data = (
b''
if global_config.manufacturer_data == (None, None)
else bytes(
core.AdvertisingData(
[
(
core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA,
struct.pack('<H', global_config.manufacturer_data[0])
+ global_config.manufacturer_data[1],
)
]
)
)
)
bigs[f'big{i}']['broadcast_audio_announcement'] = bap.BroadcastAudioAnnouncement(conf.id)
# Build advertising data types list
@@ -519,13 +505,22 @@ async def init_broadcast(
advertising_sid=i,
primary_advertising_phy=hci.Phy.LE_1M, # 2m phy config throws error - because for primary advertising channels, 1mbit is only supported
secondary_advertising_phy=hci.Phy.LE_1M, # this is the secondary advertising beeing send on non advertising channels (extendend advertising)
#advertising_tx_power= # tx power in dbm (max 20)
# Pass NO_PREFERENCE (0x7F) here for two reasons:
# 1. The Nordic SoftDevice Controller ignores this field for
# advertising sets and always returns the compile-time
# CONFIG_BT_CTLR_TX_PWR_* value. The real TX power is
# applied via the Zephyr VS Write_Tx_Power_Level command
# issued right after create_advertising_set() returns.
# 2. Bumble's HCI metadata declares this field as 1-byte
# *unsigned* (a bumble bug — the BT spec defines it as
# signed int8), so negative values would raise
# "bytes must be in range(0, 256)" at serialization.
advertising_tx_power=hci.HCI_LE_Set_Extended_Advertising_Parameters_Command.TX_POWER_NO_PREFERENCE,
#secondary_advertising_max_skip=10,
),
advertising_data=(
bigs[f'big{i}']['broadcast_audio_announcement'].get_advertising_data()
+ bytes(core.AdvertisingData(advertising_data_types))
+ advertising_manufacturer_data
),
periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
periodic_advertising_interval_min=80,
@@ -536,6 +531,48 @@ async def init_broadcast(
auto_start=True,
)
bigs[f'big{i}']['advertising_set'] = advertising_set
# NOTE: selected_tx_power below reflects the SDC's compile-time max
# (LE_Set_Ext_Adv_Params was sent with NO_PREFERENCE). The actual
# transmit power is set by the VS Write_Tx_Power_Level call below.
logging.debug(
'LE_Set_Ext_Adv_Params reports controller fallback TX power: %+d dBm (handle=%d)',
getattr(advertising_set, 'selected_tx_power', 0),
i,
)
# The Nordic SoftDevice Controller does not honor the per-set
# advertising_tx_power passed in HCI_LE_Set_Extended_Advertising_Parameters
# (it returns the compile-time CONFIG_BT_CTLR_TX_PWR_* value regardless).
# Apply the requested level via the Zephyr Vendor-Specific HCI command
# Write_Tx_Power_Level (opcode 0xFC0E), which the SDC honors per
# advertising handle. The SDC clamps the value to the nearest supported
# hardware step (max bounded by CONFIG_BT_CTLR_TX_PWR_PLUS_8).
try:
adv_handle = getattr(advertising_set, 'advertising_handle', i)
response = await device.send_command(
HCI_Write_Tx_Power_Level_Command(
handle_type=HCI_Write_Tx_Power_Level_Command.TX_POWER_HANDLE_TYPE_ADV,
connection_handle=adv_handle,
tx_power_level=global_config.advertising_tx_power,
)
)
rp = getattr(response, 'return_parameters', None)
status = getattr(rp, 'status', 0xFF) if rp is not None else 0xFF
selected = getattr(rp, 'selected_tx_power_level', None) if rp is not None else None
if status == 0 and selected is not None:
logging.info(
'Advertising TX power (VS Write_Tx_Power_Level): requested=%+d dBm, controller selected=%+d dBm (handle=%d)',
global_config.advertising_tx_power,
selected,
adv_handle,
)
else:
logging.warning(
'VS Write_Tx_Power_Level failed: status=0x%02X handle=%d requested=%+d dBm',
status, adv_handle, global_config.advertising_tx_power,
)
except Exception as e:
logging.warning('VS Write_Tx_Power_Level not supported by controller: %s', e)
logging.info('Start Periodic Advertising')
await advertising_set.start_periodic()
@@ -602,6 +639,29 @@ async def init_broadcast(
return bigs
def _lc3_file_byte_gen(filename: str, loop: bool = False):
"""Stream LC3 frames from disk as individual bytes, with optional looping.
Yields one byte (int) at a time so it is compatible with the existing
``bytes(itertools.islice(gen, bytes_per_frame))`` consumer without loading
the whole file into memory.
"""
while True:
with open(filename, 'rb') as f:
f.read(18) # skip 18-byte LC3 header
while True:
size_b = f.read(2)
if len(size_b) < 2:
break
frame_size = struct.unpack('=H', size_b)[0]
frame = f.read(frame_size)
if len(frame) < frame_size:
break
yield from frame
if not loop:
return
class Streamer():
"""
Streamer class that supports multiple input formats. See bumble for streaming from wav or device
@@ -757,13 +817,7 @@ class Streamer():
big['precoded'] = True
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
filename = big_config[i].audio_source.replace('file:', '')
lc3_bytes = read_lc3_file(filename)
lc3_frames = iter(lc3_bytes)
if big_config[i].loop:
lc3_frames = itertools.cycle(lc3_frames)
big['lc3_frames'] = lc3_frames
big['lc3_frames'] = _lc3_file_byte_gen(filename, loop=big_config[i].loop)
# use wav files and code them entirely before streaming
elif big_config[i].precode_wav and big_config[i].audio_source.endswith('.wav'):
@@ -811,7 +865,11 @@ class Streamer():
if input_format == 'auto':
raise ValueError('input format details required for alsa input')
pcm = audio_io.PcmFormat.from_str(input_format)
audio_input = AlsaArecordAudioInput(audio_source[5:], pcm)
device_name = audio_source[5:]
if device_name.startswith('dante_'):
audio_input = PyAlsaAudioInput(device_name, pcm)
else:
audio_input = AlsaArecordAudioInput(device_name, pcm)
else:
audio_input = await audio_io.create_audio_input(audio_source, input_format)
# Store early so stop_streaming can close even if open() fails
@@ -880,6 +938,9 @@ class Streamer():
if lc3_frame == b'': # Not all streams may stop at the same time
stream_finished[i] = True
continue
for q_idx in range(big.get('num_bis', 1)):
await big['iso_queues'][q_idx].write(lc3_frame)
else: # code lc3 on the fly with perf counters
# Ensure frames generator exists (so we can aclose() on stop)
frames_gen = big.get('frames_gen')
File diff suppressed because it is too large Load Diff
+488 -45
View File
@@ -10,6 +10,7 @@ from datetime import datetime
import asyncio
import random
import subprocess
import threading
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
@@ -208,6 +209,38 @@ 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')
# VoskCast state
_voskcast_task: asyncio.Task | None = None
# WhisperCast state
_whispercast_task: asyncio.Task | None = None
# 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.
_ble_loop: asyncio.AbstractEventLoop | None = None
async def _on_ble_loop(coro):
"""Submit *coro* to the BLE event loop and await the result.
Called from uvicorn's event loop. Bridges HTTP handler coroutines into
the isolated BLE loop so that serial I/O (serial_asyncio / HCI) and the
audio pipeline are never preempted by HTTP accept/read/write callbacks.
asyncio.run_coroutine_threadsafe() schedules the coroutine on _ble_loop
(thread-safe), returning a concurrent.futures.Future.
asyncio.wrap_future() adapts that into an asyncio.Future so the caller
can simply `await` it inside uvicorn's loop.
"""
assert _ble_loop is not None, "BLE loop not yet initialised"
future = asyncio.run_coroutine_threadsafe(coro, _ble_loop)
return await asyncio.wrap_future(future)
async def _init_i2c_on_startup() -> None:
# Ensure i2c-dev kernel module is loaded (required for /dev/i2c-* access)
@@ -422,6 +455,11 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
first_source = conf.bigs[0].audio_source if conf.bigs else ''
input_device_name = None
audio_mode_persist = 'Demo'
# Capture original per-BIG device names before transformation
original_input_devices = [
big.audio_source.split(':', 1)[1] if (isinstance(big.audio_source, str) and big.audio_source.startswith('device:')) else None
for big in conf.bigs
]
if any(isinstance(b.audio_source, str) and b.audio_source.startswith('device:') for b in conf.bigs):
if isinstance(first_source, str) and first_source.startswith('device:'):
input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None
@@ -564,6 +602,13 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
demo_count = sum(1 for big in conf.bigs if isinstance(big.audio_source, str) and big.audio_source.startswith('file:'))
demo_rate = int(conf.auracast_sampling_rate_hz or 0)
demo_type = None
demo_sources = [
str(b.audio_source)
for b in conf.bigs
if isinstance(b.audio_source, str) and b.audio_source.startswith('file:')
]
is_demo_tone = bool(demo_sources) and all('test_tone_1k_' in src for src in demo_sources)
demo_content = '1 kHz test tone' if is_demo_tone else 'Program material'
if demo_count > 0 and demo_rate > 0:
if demo_rate in (48000, 24000, 16000):
demo_type = f"{demo_count} × {demo_rate//1000}kHz"
@@ -574,6 +619,7 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
'languages': [big.language for big in conf.bigs],
'audio_mode': audio_mode_persist,
'input_device': input_device_name,
'input_devices': original_input_devices,
'program_info': [getattr(big, 'program_info', None) for big in conf.bigs],
'gain': [getattr(big, 'input_gain', 1.0) for big in conf.bigs],
'auracast_sampling_rate_hz': conf.auracast_sampling_rate_hz,
@@ -585,13 +631,15 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
'analog_stereo_mode': getattr(conf.bigs[0], 'analog_stereo_mode', False) if conf.bigs else False,
'analog_gain_db_left': getattr(conf, 'analog_gain_db_left', 0.0),
'analog_gain_db_right': getattr(conf, 'analog_gain_db_right', 0.0),
'advertising_tx_power': getattr(conf, 'advertising_tx_power', 8),
'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None),
'big_ids': [getattr(big, 'id', DEFAULT_BIG_ID) for big in conf.bigs],
'big_random_addresses': [getattr(big, 'random_address', DEFAULT_RANDOM_ADDRESS) for big in conf.bigs],
'demo_total_streams': demo_count,
'demo_stream_type': demo_type,
'demo_content': demo_content,
'is_streaming': auto_started,
'demo_sources': [str(b.audio_source) for b in conf.bigs if isinstance(b.audio_source, str) and b.audio_source.startswith('file:')],
'demo_sources': demo_sources,
}
return mc, persisted
except HTTPException:
@@ -602,7 +650,10 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
@app.post("/init")
async def initialize(conf: auracast_config.AuracastConfigGroup):
"""Initializes the primary broadcaster on the streamer thread."""
"""Initializes the primary broadcaster on the BLE loop."""
return await _on_ble_loop(_initialize_impl(conf))
async def _initialize_impl(conf: auracast_config.AuracastConfigGroup):
async with _stream_lock:
global multicaster1, global_config_group
mc, persisted = await init_radio(TRANSPORT1, conf, multicaster1)
@@ -612,7 +663,10 @@ async def initialize(conf: auracast_config.AuracastConfigGroup):
@app.post("/init2")
async def initialize2(conf: auracast_config.AuracastConfigGroup):
"""Initializes the secondary broadcaster on the streamer thread."""
"""Initializes the secondary broadcaster on the BLE loop."""
return await _on_ble_loop(_initialize2_impl(conf))
async def _initialize2_impl(conf: auracast_config.AuracastConfigGroup):
async with _stream_lock:
global multicaster2
mc, persisted = await init_radio(TRANSPORT2, conf, multicaster2)
@@ -631,7 +685,11 @@ async def set_led_enabled(body: dict):
@app.post("/stop_audio")
async def stop_audio():
"""Stops streaming on both multicaster1 and multicaster2 (worker thread)."""
"""Stops streaming on both multicasters via the BLE loop."""
return await _on_ble_loop(_stop_audio_impl())
async def _stop_audio_impl():
"""Runs on BLE loop: stops all streamers and persists is_streaming=False."""
try:
was_running = await _stop_all()
@@ -657,6 +715,208 @@ async def stop_audio():
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": "<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("/start_voskcast")
async def start_voskcast(body: dict = {}):
"""Start Vosk STT → TextCast. Body (optional): {"model": "...", "device": "ch1"}"""
return await _on_ble_loop(_start_voskcast_impl(body))
async def _start_voskcast_impl(body: dict) -> dict:
global _voskcast_task
from auracast.vosk_textcast import broadcast_vosk, DEFAULT_MODEL_PATH
model = body.get('model') or DEFAULT_MODEL_PATH
device = body.get('device', 'ch1')
await _stop_all()
await _stop_textcast_impl()
await _stop_voskcast_impl()
_voskcast_task = asyncio.get_event_loop().create_task(
broadcast_vosk(TRANSPORT1, model, device)
)
settings = {
'is_streaming': True,
'audio_mode': 'VoskCast',
'voskcast_is_streaming': True,
'voskcast_device': device,
'voskcast_model': model,
'timestamp': datetime.utcnow().isoformat(),
}
save_stream_settings(settings)
_led_on()
log.info("VoskCast started (device=%s, model=%s)", device, model)
return {"status": "started"}
@app.post("/stop_voskcast")
async def stop_voskcast():
"""Stop an active VoskCast broadcast."""
return await _on_ble_loop(_stop_voskcast_impl())
async def _stop_voskcast_impl() -> dict:
global _voskcast_task
was_running = False
if _voskcast_task is not None and not _voskcast_task.done():
was_running = True
_voskcast_task.cancel()
try:
await asyncio.wait_for(asyncio.shield(_voskcast_task), timeout=4.0)
except (asyncio.CancelledError, asyncio.TimeoutError, Exception):
pass
_voskcast_task = None
_led_off()
settings = load_stream_settings() or {}
if settings.get('audio_mode') == 'VoskCast':
settings['is_streaming'] = False
settings['voskcast_is_streaming'] = False
settings['timestamp'] = datetime.utcnow().isoformat()
save_stream_settings(settings)
log.info("VoskCast stopped")
return {"status": "stopped", "was_running": was_running}
@app.post("/start_whispercast")
async def start_whispercast(body: dict = {}):
"""Start faster-whisper → TextCast. Body (optional): {"model": "tiny.en", "device": "ch1"}"""
return await _on_ble_loop(_start_whispercast_impl(body))
async def _start_whispercast_impl(body: dict) -> dict:
global _whispercast_task
from auracast.faster_whisper_textcast import broadcast_whisper
model = body.get('model', 'tiny.en')
device = body.get('device', 'ch1')
await _stop_all()
await _stop_textcast_impl()
await _stop_voskcast_impl()
await _stop_whispercast_impl()
_whispercast_task = asyncio.get_event_loop().create_task(
broadcast_whisper(TRANSPORT1, model, device)
)
settings = {
'is_streaming': True,
'audio_mode': 'WhisperCast',
'whispercast_is_streaming': True,
'whispercast_device': device,
'whispercast_model': model,
'timestamp': datetime.utcnow().isoformat(),
}
save_stream_settings(settings)
_led_on()
log.info("WhisperCast started (device=%s, model=%s)", device, model)
return {"status": "started"}
@app.post("/stop_whispercast")
async def stop_whispercast():
"""Stop an active WhisperCast broadcast."""
return await _on_ble_loop(_stop_whispercast_impl())
async def _stop_whispercast_impl() -> dict:
global _whispercast_task
was_running = False
if _whispercast_task is not None and not _whispercast_task.done():
was_running = True
_whispercast_task.cancel()
try:
await asyncio.wait_for(asyncio.shield(_whispercast_task), timeout=5.0)
except (asyncio.CancelledError, asyncio.TimeoutError, Exception):
pass
_whispercast_task = None
_led_off()
settings = load_stream_settings() or {}
if settings.get('audio_mode') == 'WhisperCast':
settings['is_streaming'] = False
settings['whispercast_is_streaming'] = False
settings['timestamp'] = datetime.utcnow().isoformat()
save_stream_settings(settings)
log.info("WhisperCast 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.
@@ -681,9 +941,9 @@ async def set_adc_gain(payload: dict):
@app.post("/stream_lc3")
async def send_audio(audio_data: dict[str, str]):
"""Sends a block of pre-coded LC3 audio via the worker."""
"""Sends a block of pre-coded LC3 audio via the BLE loop."""
try:
await _stream_lc3(audio_data, list(global_config_group.bigs))
await _on_ble_loop(_stream_lc3(audio_data, list(global_config_group.bigs)))
return {"status": "audio_sent"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -715,6 +975,15 @@ 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()
)
status["voskcast_is_streaming"] = (
_voskcast_task is not None and not _voskcast_task.done()
)
status["whispercast_is_streaming"] = (
_whispercast_task is not None and not _whispercast_task.done()
)
return status
@@ -755,11 +1024,12 @@ async def _autostart_from_settings():
big_ids = settings.get('big_ids') or []
big_addrs = settings.get('big_random_addresses') or []
stream_password = settings.get('stream_password')
tx_power = int(settings.get('advertising_tx_power', 8))
original_ts = settings.get('timestamp')
previously_streaming = bool(settings.get('is_streaming'))
log.info(
"[AUTOSTART][PRIMARY] loaded settings: previously_streaming=%s audio_mode=%s rate=%s octets=%s pres_delay=%s qos_preset=%s immediate_rendering=%s assisted_listening_stream=%s demo_sources=%s",
"[AUTOSTART][PRIMARY] loaded settings: previously_streaming=%s audio_mode=%s rate=%s octets=%s pres_delay=%s qos_preset=%s immediate_rendering=%s assisted_listening_stream=%s tx_power=%+d dBm demo_sources=%s",
previously_streaming,
audio_mode,
rate,
@@ -768,6 +1038,7 @@ async def _autostart_from_settings():
saved_qos_preset,
immediate_rendering,
assisted_listening_stream,
tx_power,
(settings.get('demo_sources') or []),
)
@@ -817,6 +1088,7 @@ async def _autostart_from_settings():
immediate_rendering=immediate_rendering,
assisted_listening_stream=assisted_listening_stream,
presentation_delay_us=pres_delay if pres_delay is not None else 40000,
advertising_tx_power=tx_power,
bigs=bigs,
)
# Set num_bis for stereo mode if needed
@@ -886,6 +1158,7 @@ async def _autostart_from_settings():
presentation_delay_us=pres_delay if pres_delay is not None else 40000,
analog_gain_db_left=settings.get('analog_gain_db_left', 0.0),
analog_gain_db_right=settings.get('analog_gain_db_right', 0.0),
advertising_tx_power=tx_power,
bigs=bigs,
)
# Set num_bis for stereo mode if needed
@@ -921,10 +1194,11 @@ async def _autostart_from_settings():
big_ids = settings.get('big_ids') or []
big_addrs = settings.get('big_random_addresses') or []
stream_password = settings.get('stream_password')
tx_power = int(settings.get('advertising_tx_power', 8))
original_ts = settings.get('timestamp')
previously_streaming = bool(settings.get('is_streaming'))
log.info(
"[AUTOSTART][SECONDARY] loaded settings: previously_streaming=%s audio_mode=%s rate=%s octets=%s pres_delay=%s qos_preset=%s immediate_rendering=%s assisted_listening_stream=%s demo_sources=%s",
"[AUTOSTART][SECONDARY] loaded settings: previously_streaming=%s audio_mode=%s rate=%s octets=%s pres_delay=%s qos_preset=%s immediate_rendering=%s assisted_listening_stream=%s tx_power=%+d dBm demo_sources=%s",
previously_streaming,
audio_mode,
rate,
@@ -933,6 +1207,7 @@ async def _autostart_from_settings():
saved_qos_preset,
immediate_rendering,
assisted_listening_stream,
tx_power,
(settings.get('demo_sources') or []),
)
if not previously_streaming:
@@ -972,6 +1247,7 @@ async def _autostart_from_settings():
immediate_rendering=immediate_rendering,
assisted_listening_stream=assisted_listening_stream,
presentation_delay_us=pres_delay if pres_delay is not None else 40000,
advertising_tx_power=tx_power,
bigs=bigs,
)
conf.qos_config = QOS_PRESET_MAP.get(saved_qos_preset, QOS_PRESET_MAP["Fast"])
@@ -1041,6 +1317,7 @@ async def _autostart_from_settings():
presentation_delay_us=pres_delay if pres_delay is not None else 40000,
analog_gain_db_left=settings.get('analog_gain_db_left', 0.0),
analog_gain_db_right=settings.get('analog_gain_db_right', 0.0),
advertising_tx_power=tx_power,
bigs=bigs,
)
conf.qos_config = QOS_PRESET_MAP.get(saved_qos_preset, QOS_PRESET_MAP["Fast"])
@@ -1058,6 +1335,19 @@ async def _autostart_from_settings():
await do_primary()
await do_secondary()
async def _ble_startup():
"""I2C init, ADC level reset, and autostart task scheduling on the BLE loop.
Bridged from _startup_autostart_event() so that these async subprocess
calls and the long-lived autostart coroutine all run on _ble_loop, never
on uvicorn's HTTP loop.
"""
await _init_i2c_on_startup()
await _set_adc_level(0.0, 0.0)
log.info("[STARTUP] Scheduling autostart task on BLE loop")
asyncio.create_task(_autostart_from_settings())
@app.on_event("startup")
async def _startup_autostart_event():
# Spawn the autostart task without blocking startup
@@ -1078,12 +1368,11 @@ async def _startup_autostart_event():
# Hydrate settings cache once to avoid disk I/O during /status
_load_led_settings()
_init_settings_cache_from_disk()
await _init_i2c_on_startup()
# Ensure ADC mixer level is set at startup (default 0 dB)
await _set_adc_level(0.0, 0.0)
refresh_pw_cache()
log.info("[STARTUP] Scheduling autostart task")
asyncio.create_task(_autostart_from_settings())
# I2C init, ADC setup and the autostart task must run on the BLE loop so
# they share the same event loop as the Bumble HCI transport.
log.info("[STARTUP] Bridging I2C init and autostart to BLE loop")
asyncio.run_coroutine_threadsafe(_ble_startup(), _ble_loop)
@app.get("/audio_inputs_pw_usb")
async def audio_inputs_pw_usb():
@@ -1154,6 +1443,9 @@ async def refresh_audio_devices():
@app.post("/shutdown")
async def shutdown():
"""Stops broadcasting and releases all audio/Bluetooth resources."""
return await _on_ble_loop(_shutdown_impl())
async def _shutdown_impl():
try:
await _stop_all()
return {"status": "stopped"}
@@ -1166,6 +1458,9 @@ async def system_reboot():
Requires the service user to have passwordless sudo permissions to run 'reboot'.
"""
return await _on_ble_loop(_system_reboot_impl())
async def _system_reboot_impl():
try:
# Best-effort: stop any active streaming cleanly WITHOUT persisting state
try:
@@ -1189,47 +1484,27 @@ async def system_reboot():
@app.post("/restart_dep")
async def restart_dep():
"""Restart DEP by running dep.sh stop then dep.sh start in the dep directory.
"""Restart DEP via systemctl restart dep.service.
Requires the service user to have passwordless sudo permissions to run dep.sh.
Requires the service user to have passwordless sudo permissions for systemctl.
"""
try:
# Get the dep directory path (dep.sh is in dante_package subdirectory)
dep_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'dep', 'dante_package')
# Run dep.sh stop first
log.info("Stopping DEP...")
stop_process = await asyncio.create_subprocess_exec(
"sudo", "bash", "dep.sh", "stop",
cwd=dep_dir,
log.info("Restarting DEP via systemctl...")
proc = await asyncio.create_subprocess_exec(
"sudo", "systemctl", "restart", "dep.service",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stop_stdout, stop_stderr = await stop_process.communicate()
if stop_process.returncode != 0:
error_msg = stop_stderr.decode() if stop_stderr else "Unknown error"
log.error(f"Failed to stop DEP: {error_msg}")
raise HTTPException(status_code=500, detail=f"Failed to stop DEP: {error_msg}")
# Run dep.sh start after stop succeeds
log.info("Starting DEP...")
start_process = await asyncio.create_subprocess_exec(
"sudo", "bash", "dep.sh", "start",
cwd=dep_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
start_stdout, start_stderr = await start_process.communicate()
if start_process.returncode == 0:
stdout, stderr = await proc.communicate()
if proc.returncode == 0:
log.info("DEP restarted successfully")
return {"status": "success", "message": "DEP restarted successfully"}
else:
error_msg = start_stderr.decode() if start_stderr else "Unknown error"
log.error(f"Failed to start DEP: {error_msg}")
raise HTTPException(status_code=500, detail=f"Failed to start DEP: {error_msg}")
error_msg = stderr.decode() if stderr else "Unknown error"
log.error(f"Failed to restart DEP: {error_msg}")
raise HTTPException(status_code=500, detail=f"Failed to restart DEP: {error_msg}")
except HTTPException:
raise
except Exception as e:
@@ -1322,6 +1597,9 @@ async def check_update():
@app.post("/system_update")
async def system_update():
"""Update application: git pull main branch (latest tag), poetry install, restart services."""
return await _on_ble_loop(_system_update_impl())
async def _system_update_impl():
try:
# Best-effort: stop any active streaming cleanly
try:
@@ -1789,5 +2067,170 @@ if __name__ == '__main__':
level=os.environ.get('LOG_LEVEL', log.INFO),
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
)
# ── GIL switch interval ─────────────────────────────────────────────────
# CPython releases the GIL every sys.getswitchinterval() seconds (default
# 5 ms). The audio pipeline fires every 10 ms, so a 5 ms granularity
# means up to half a frame period can be wasted waiting for the GIL.
# Reducing to 1 ms gives the BLE thread much tighter access.
import sys
sys.setswitchinterval(0.001)
log.info("GIL switch interval set to 1 ms")
# ── BLE / audio event loop ──────────────────────────────────────────────
# Bumble (serial_asyncio / HCI) and the audio pipeline run exclusively on
# this loop. Uvicorn's HTTP accept/read/write callbacks run on a separate
# asyncio loop in the main thread, so they can never stall BLE advertising
# or audio encoding.
#
# Route handlers that touch Bumble objects call _on_ble_loop(), which uses
# asyncio.run_coroutine_threadsafe() + asyncio.wrap_future() to submit the
# coroutine to _ble_loop and await the result back in uvicorn's loop.
# Hot-path read-only endpoints (/status, /audio_level*) access
# multicaster state directly Python's GIL makes attribute reads safe.
def _pthread_sched_lib():
"""Return a ctypes handle with correctly typed pthread scheduling symbols.
Uses RTLD_DEFAULT (ctypes.CDLL(None)) to resolve symbols from all
currently loaded shared libraries. This handles both:
- glibc < 2.34: pthread_self/pthread_setschedparam live in libpthread.so.0
- glibc >= 2.34: pthreads merged into libc.so.6
using find_library("c") would miss libpthread on older glibc and cause
a NULL function pointer → SEGV when called.
Explicit restype/argtypes are mandatory: pthread_t is c_ulong (64-bit
on ARM64/x86-64) but ctypes defaults to c_int (32-bit), truncating
the thread handle and causing a SEGV inside pthread_setschedparam.
"""
import ctypes
SCHED_FIFO = 1
SCHED_OTHER = 0
class SchedParam(ctypes.Structure):
_fields_ = [("sched_priority", ctypes.c_int)]
lib = ctypes.CDLL(None, use_errno=True) # RTLD_DEFAULT
lib.pthread_self.restype = ctypes.c_ulong
lib.pthread_self.argtypes = []
lib.pthread_getschedparam.restype = ctypes.c_int
lib.pthread_getschedparam.argtypes = [
ctypes.c_ulong,
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(SchedParam),
]
lib.pthread_setschedparam.restype = ctypes.c_int
lib.pthread_setschedparam.argtypes = [
ctypes.c_ulong,
ctypes.c_int,
ctypes.POINTER(SchedParam),
]
return lib, SchedParam, SCHED_FIFO, SCHED_OTHER
def _configure_ble_thread_scheduling():
"""Confirm or establish SCHED_FIFO for the BLE/audio thread.
When launched via the systemd unit (CPUSchedulingPolicy=fifo), new
threads inherit the process RT policy automatically just log and
return. When run directly (development), attempt to elevate to
SCHED_FIFO/30 (requires CAP_SYS_NICE), falling back gracefully.
"""
import ctypes
try:
lib, SchedParam, SCHED_FIFO, _ = _pthread_sched_lib()
tid = lib.pthread_self()
policy = ctypes.c_int(-1)
param = SchedParam(0)
lib.pthread_getschedparam(tid, ctypes.byref(policy), ctypes.byref(param))
if policy.value == SCHED_FIFO:
log.info("[BLE-LOOP] Already SCHED_FIFO priority=%d (inherited from systemd)",
param.sched_priority)
return
param.sched_priority = 30
ret = lib.pthread_setschedparam(tid, SCHED_FIFO, ctypes.byref(param))
if ret == 0:
log.info("[BLE-LOOP] SCHED_FIFO priority=30 set")
else:
err = ctypes.get_errno()
log.warning("[BLE-LOOP] SCHED_FIFO failed (errno=%d: %s) "
"use systemd CPUSchedulingPolicy=fifo or grant CAP_SYS_NICE",
err, os.strerror(err))
try:
os.setpriority(os.PRIO_PROCESS, 0,
os.getpriority(os.PRIO_PROCESS, 0) - 5)
except PermissionError:
pass
except Exception as exc:
log.warning("[BLE-LOOP] Scheduling setup error: %s", exc)
def _configure_http_thread_scheduling():
"""Demote the HTTP (uvicorn) thread to SCHED_OTHER + nice=+10.
When systemd sets CPUSchedulingPolicy=fifo, every thread in the
process including uvicorn's main loop inherits SCHED_FIFO.
We demote the HTTP thread back to SCHED_OTHER so the BLE thread
always wins CPU arbitration when both are runnable.
Lowering scheduling policy never requires special privileges.
"""
import ctypes
try:
lib, SchedParam, SCHED_FIFO, SCHED_OTHER = _pthread_sched_lib()
tid = lib.pthread_self()
policy = ctypes.c_int(-1)
param = SchedParam(0)
lib.pthread_getschedparam(tid, ctypes.byref(policy), ctypes.byref(param))
if policy.value == SCHED_FIFO:
param.sched_priority = 0
ret = lib.pthread_setschedparam(tid, SCHED_OTHER, ctypes.byref(param))
if ret == 0:
log.info("[HTTP] Demoted SCHED_FIFO → SCHED_OTHER")
else:
err = ctypes.get_errno()
log.warning("[HTTP] Could not demote from SCHED_FIFO (errno=%d)", err)
else:
log.info("[HTTP] Already SCHED_OTHER, no demotion needed")
except Exception as exc:
log.warning("[HTTP] Scheduling demotion error: %s", exc)
try:
os.nice(10)
log.info("[HTTP] nice=+10 (lower priority)")
except Exception as exc:
log.debug("[HTTP] os.nice: %s", exc)
_ble_loop_ready = threading.Event()
def _run_ble_loop():
# Confirm or establish RT scheduling before entering the event loop.
_configure_ble_thread_scheduling()
async def _ble_runner():
global _ble_loop
_ble_loop = asyncio.get_running_loop()
_ble_loop_ready.set()
# Keep the loop alive; it is stopped when the process exits because
# this is a daemon thread.
await asyncio.Event().wait()
asyncio.run(_ble_runner())
_ble_thread = threading.Thread(target=_run_ble_loop, name="ble-loop", daemon=True)
_ble_thread.start()
if not _ble_loop_ready.wait(timeout=5):
log.error("BLE event loop failed to start within 5 s aborting")
raise RuntimeError("BLE event loop startup timeout")
log.info("BLE event loop started on thread '%s'", _ble_thread.name)
# ── HTTP / uvicorn event loop (main thread) ─────────────────────────────
# Demote the HTTP thread from SCHED_FIFO (if set by systemd) to
# SCHED_OTHER + nice=+10 so the BLE thread always preempts it.
_configure_http_thread_scheduling()
# Bind to localhost only for security: prevents network access, only frontend on same machine can connect
uvicorn.run(app, host="127.0.0.1", port=5000, access_log=False)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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()
+3 -3
View File
@@ -21,12 +21,12 @@ def read_lc3_file(filepath):
logging.info('frame_duration %s', frame_duration)
logging.info('stream_length %s', stream_length)
lc3_bytes= b''
chunks = []
while True:
b = f_lc3.read(2)
if b == b'':
break
lc3_frame_size = struct.unpack('=H', b)[0]
lc3_bytes += f_lc3.read(lc3_frame_size)
chunks.append(f_lc3.read(lc3_frame_size))
return lc3_bytes
return b''.join(chunks)
+270
View File
@@ -0,0 +1,270 @@
"""Vosk speech-to-text → TextCast streamer.
Captures mono audio from an analog ALSA/sounddevice input, runs Vosk
offline ASR in a background thread, and broadcasts recognised text over
the TextCast BLE broadcast using the same SDU framing as text_multicast.py.
Usage (CLI):
poetry run python -m auracast.vosk_textcast \\
--model /path/to/vosk-model-en-us \\
--device ch1 \\
--transport serial:/dev/ttyAMA3,1000000,rtscts
Environment:
VOSK_MODEL_PATH default Vosk model directory
AURACAST_TRANSPORT default HCI transport string
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import threading
import time
from typing import Optional
import numpy as np
import samplerate
import sounddevice as sd
from auracast import auracast_config, multicast
from auracast.text_multicast import (
SDU_SIZE,
SDU_INTERVAL_US,
_make_text_frame,
_make_idle_frame,
)
log = logging.getLogger('vosk_textcast')
VOSK_SAMPLE_RATE = 16_000 # Vosk models expect 16 kHz
CAPTURE_SAMPLE_RATE = 48_000 # Hardware capture rate (always 48 kHz)
BLOCK_FRAMES_48K = 4800 # 100 ms blocks at 48 kHz → 1600 frames at 16 kHz
CAPTION_HOLD_S = 4.0 # Keep caption visible N seconds after last speech
BROADCAST_NAME = 'LiveCaption'
DEFAULT_MODEL_PATH = os.environ.get(
'VOSK_MODEL_PATH',
os.path.expanduser('~/vosk-model-en-us'),
)
def _tail_to_fit(text: str, max_bytes: int) -> str:
"""Return the tail of *text* that fits in *max_bytes* UTF-8 bytes."""
encoded = text.encode('utf-8')
if len(encoded) <= max_bytes:
return text
tail = encoded[-max_bytes:].decode('utf-8', errors='ignore')
sp = tail.find(' ')
return tail[sp + 1:] if sp != -1 else tail
def _new_words(old: str, new: str) -> str:
"""Return the words appended to *new* beyond the shared prefix with *old*.
If *new* doesn't start with *old* (different utterance), return *new* in full.
"""
old_words = old.split()
new_words = new.split()
if new_words[:len(old_words)] == old_words:
extra = new_words[len(old_words):]
return ' '.join(extra)
return new
def _resolve_device(device: str) -> Optional[int]:
"""Return sounddevice index for a name or numeric string, or None for default."""
if not device:
return None
if device.isdigit():
return int(device)
for i, d in enumerate(sd.query_devices()):
if d['name'] == device and d['max_input_channels'] > 0:
return i
log.warning("Device '%s' not found in sounddevice list using default input", device)
return None
async def _iso_write_loop(bigs: dict, shared: dict, lock: threading.Lock) -> None:
"""ISO SDU write loop.
Runs at ~10 ms per iteration (flow-controlled by the BLE controller).
Sends the current recognised text (partial or final) as-is.
"""
iso_queue = bigs['big0']['iso_queue']
last_sent: str = ''
while True:
now = time.monotonic()
with lock:
text: str = shared.get('text', '')
expiry: float = shared.get('expiry', 0.0)
if text and now < expiry:
display_text = _tail_to_fit(text, SDU_SIZE - 2)
if display_text != last_sent:
log.info("Caption: %s", display_text)
last_sent = display_text
frame = _make_text_frame(display_text)
else:
if last_sent:
log.info("Caption cleared")
last_sent = ''
with lock:
shared['text'] = ''
frame = _make_idle_frame()
await iso_queue.write(frame)
def _vosk_thread(
model_path: str,
device: str,
shared: dict,
lock: threading.Lock,
stop_event: threading.Event,
) -> None:
"""Blocking audio capture + Vosk recognition loop. Runs in a daemon thread."""
try:
from vosk import KaldiRecognizer, Model # type: ignore
except ImportError:
log.error("vosk is not installed. Run: poetry add vosk")
return
log.info("Loading Vosk model from %s", model_path)
model = Model(model_path)
rec = KaldiRecognizer(model, VOSK_SAMPLE_RATE)
rec.SetMaxAlternatives(0)
rec.SetWords(False)
resampler = samplerate.Resampler('sinc_fastest', channels=1)
ratio = VOSK_SAMPLE_RATE / CAPTURE_SAMPLE_RATE
dev_idx = _resolve_device(device)
last_word_count = [0] # word count of last partial sent to display
def _cb(indata: np.ndarray, frames: int, time_info, status) -> None:
if status:
log.warning("Audio status: %s", status)
if stop_event.is_set():
raise sd.CallbackStop()
# Resample 48 kHz → 16 kHz
mono = indata[:, 0].astype(np.float32)
downsampled = resampler.process(mono, ratio, end_of_input=False)
pcm16 = (downsampled * 32767).astype(np.int16).tobytes()
if rec.AcceptWaveform(pcm16):
result = json.loads(rec.Result())
final_text = result.get('text', '').strip()
if final_text:
log.info("Final: %s", final_text)
with lock:
shared['text'] = _tail_to_fit(final_text, SDU_SIZE - 2)
shared['expiry'] = time.monotonic() + CAPTION_HOLD_S
last_word_count[0] = 0 # reset for next sentence
else:
partial_text = json.loads(rec.PartialResult()).get('partial', '').strip()
if partial_text:
wc = len(partial_text.split())
if wc > last_word_count[0]: # new word arrived
last_word_count[0] = wc
with lock:
shared['text'] = _tail_to_fit(partial_text, SDU_SIZE - 2)
shared['expiry'] = time.monotonic() + CAPTION_HOLD_S
try:
with sd.InputStream(
samplerate=CAPTURE_SAMPLE_RATE,
blocksize=BLOCK_FRAMES_48K,
device=dev_idx,
dtype='float32',
channels=1,
callback=_cb,
):
log.info("Vosk listening on device '%s' (idx=%s) …", device, dev_idx)
stop_event.wait()
except Exception as exc:
log.error("Vosk audio thread error: %s", exc, exc_info=True)
async def broadcast_vosk(
transport: str,
model_path: str = DEFAULT_MODEL_PATH,
device: str = 'ch1',
) -> None:
"""Start a Vosk STT → TextCast broadcast. Runs until cancelled."""
model_path = os.path.expanduser(model_path)
if not os.path.exists(model_path):
raise FileNotFoundError(
f"Vosk model not found at '{model_path}'. "
"Download from https://alphacephei.com/vosk/models and set VOSK_MODEL_PATH."
)
config = auracast_config.AuracastConfigGroup(
bigs=[
auracast_config.AuracastBigConfig(
name=BROADCAST_NAME,
program_info='Live Captions',
language='eng',
audio_source='file:dummy',
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,
)
shared: dict = {'text': '', 'expiry': 0.0}
lock = threading.Lock()
stop_event = threading.Event()
async with multicast.create_device(config) as ble_device:
bigs = await multicast.init_broadcast(ble_device, config, config.bigs)
t = threading.Thread(
target=_vosk_thread,
args=(model_path, device, shared, lock, stop_event),
daemon=True,
)
t.start()
log.info("VoskCast started (device=%s, model=%s)", device, model_path)
try:
await _iso_write_loop(bigs, shared, lock)
except asyncio.CancelledError:
log.info("VoskCast cancelled shutting down")
stop_event.set()
t.join(timeout=3.0)
raise
def main() -> None:
parser = argparse.ArgumentParser(description='Vosk STT → Auracast TextCast')
parser.add_argument(
'--model',
default=DEFAULT_MODEL_PATH,
help=f'Path to Vosk model directory (default: {DEFAULT_MODEL_PATH})',
)
parser.add_argument(
'--device',
default='ch1',
help='sounddevice input device name or index (default: ch1)',
)
parser.add_argument(
'--transport',
default=os.environ.get('AURACAST_TRANSPORT', 'serial:/dev/ttyAMA3,1000000,rtscts'),
help='Bumble HCI transport string',
)
args = parser.parse_args()
multicast.run_async(broadcast_vosk(args.transport, args.model, args.device))
if __name__ == '__main__':
main()
@@ -0,0 +1 @@
Placeholder file to have the activation folder available, otherwise dante activation script fails with 'Unable to write'.
@@ -1,5 +1,5 @@
{
"trialMode": true,
"trialMode": false,
"$schema": "./dante.json_schema.json",
"platform":
{
@@ -16,7 +16,7 @@
48000
],
"samplesPerPeriod" : 16,
"periodsPerBuffer" : 300,
"periodsPerBuffer" : 150,
"networkLatencyMinMs" : 2,
"networkLatencyDefaultMs" : 5,
"supportedEncodings" :
@@ -24,7 +24,10 @@
"PCM16"
],
"defaultEncoding" : "PCM16",
"numDepCores" : 1
"numDepCores" :
[
3
]
},
"network" :
{
@@ -50,31 +53,32 @@
"alsaAsrc":
{
"enableAlsaAsrc": true,
"cpuAffinity": 3,
"deviceConfigurations": [
{
"deviceIdentifier": "hw:0,0",
"deviceIdentifier": "hw:6,0,0",
"direction": "playback",
"bitDepth": 16,
"numOpenChannels": 6,
"alsaChannelRange": "0-5",
"danteChannelRange": "0-5",
"bufferSize": 4800,
"bufferSize": 960,
"samplesPerPeriod": 16
}
]
},
"product" :
{
"manfId" : "Audinate",
"manfName" : "Audinate Pty Ltd",
"modelId" : "OEMDEP",
"modelName" : "Linux Dante Embedded Platform",
"manfId" : "SummitFC",
"manfName" : "Summitwave FlexCo",
"modelId" : "TX",
"modelName" : "Summitwave TX",
"modelVersion" :
{
"major" : 9,
"minor" : 9,
"bugfix" : 99
"major" : 1,
"minor" : 0,
"bugfix" : 0
},
"devicePrefix" : "DEP"
"devicePrefix" : "SW-TX"
}
}
+8
View File
@@ -45,4 +45,12 @@ sudo openocd \
-c "nrf54l.dap apreg 2 0x000 0x0" \
-c "shutdown"
sudo openocd \
-f ./raspberrypi-${INTERFACE}.cfg \
-c "init" \
-c "nrf54l.dap apreg 2 0x000 0x4" \
-c "sleep 100" \
-c "nrf54l.dap apreg 2 0x000 0x0" \
-c "shutdown"
echo "Flashing complete."
+11340 -11283
View File
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
# NetworkManager dispatcher script: 10-link-local-mgmt
#
# Temporarily suppresses IPv4 link-local when a DHCP address is available,
# using nmcli device modify (active session only, NOT saved to the profile).
# The persistent profile always keeps ipv4.link-local=enabled so that
# direct-connect (no DHCP) plug-ins always activate and trigger events.
# Avahi is reloaded on each event — no /etc/avahi/hosts file, avahi uses
# natural per-interface advertisement so each segment gets the right IP.
#
# Triggers: up, down, dhcp4-change on ethernet interfaces
# Install to: /etc/NetworkManager/dispatcher.d/10-link-local-mgmt
# Permissions: root:root 0755
INTERFACE="$1"
ACTION="$2"
# Only handle ethernet interfaces
if [[ ! "$INTERFACE" =~ ^eth ]]; then
exit 0
fi
reload_avahi() {
systemctl reload avahi-daemon 2>/dev/null || systemctl restart avahi-daemon 2>/dev/null
logger -t nm-link-local "[$INTERFACE] $ACTION — avahi reloaded"
}
case "$ACTION" in
up)
# On 'up' the interface may still carry a stale DHCP address from the previous
# session (NM hasn't cleaned it up yet). Reading ip-addr here is unreliable.
# Always re-enable link-local as a clean slate; let dhcp4-change suppress it
# later if a real DHCP lease is obtained.
logger -t nm-link-local "[$INTERFACE] Up — ensuring link-local active (clean slate)"
(sleep 2 && nmcli device modify "$INTERFACE" ipv4.link-local enabled 2>/dev/null \
&& logger -t nm-link-local "[$INTERFACE] Link-local explicitly enabled on up") &
reload_avahi
;;
dhcp4-change)
# dhcp4-change fires only when DHCP actually succeeds (new/renewed lease).
# At this point the DHCP IP is reliably present — safe to read and suppress link-local.
DHCP_IP=$(ip -4 addr show "$INTERFACE" 2>/dev/null \
| grep -oP '(?<=inet\s)\d+(\.\d+){3}' \
| grep -v '^127\.' \
| grep -v '^169\.254\.' \
| head -n1)
if [ -n "$DHCP_IP" ]; then
logger -t nm-link-local "[$INTERFACE] DHCP $DHCP_IP confirmed — suppressing link-local (session only)"
# Run in background after a delay — nmcli blocks on NM, which is waiting for
# this dispatcher to return, causing a deadlock if called synchronously.
(sleep 2 && nmcli device modify "$INTERFACE" ipv4.link-local disabled 2>/dev/null \
&& logger -t nm-link-local "[$INTERFACE] Link-local suppressed for current session") &
fi
reload_avahi
;;
down)
# NOTE: a carrier-change does NOT fully reset session-level 'device modify' state.
# The re-enable is therefore handled in the 'up' handler when no DHCP is detected.
logger -t nm-link-local "[$INTERFACE] Down — link-local will be re-enabled on next up without DHCP"
reload_avahi
;;
esac
+2
View File
@@ -10,6 +10,8 @@ WorkingDirectory=/home/caster/bumble-auracast/src/auracast/server
ExecStart=/home/caster/bumble-auracast/src/auracast/server/start_frontend_https.sh
Restart=on-failure
Environment=LOG_LEVEL=INFO
AllowedCPUs=0
CPUAffinity=0
[Install]
WantedBy=multi-user.target
+2
View File
@@ -9,6 +9,8 @@ ExecStart=/home/caster/bumble-auracast/.venv/bin/python src/auracast/multicast_s
Restart=on-failure
Environment=PYTHONUNBUFFERED=1
Environment=LOG_LEVEL=INFO
AllowedCPUs=0
CPUAffinity=0
[Install]
WantedBy=default.target
+5 -2
View File
@@ -1,6 +1,7 @@
[Unit]
Description=Auracast Backend Server
After=network.target
After=network.target dep.service
Wants=dep.service
[Service]
Type=simple
@@ -10,8 +11,10 @@ Restart=on-failure
Environment=PYTHONUNBUFFERED=1
Environment=LOG_LEVEL=INFO
CPUSchedulingPolicy=fifo
CPUSchedulingPriority=99
CPUSchedulingPriority=10
LimitRTPRIO=99
AllowedCPUs=0,1,2
CPUAffinity=0,1,2
[Install]
WantedBy=default.target
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=DEP (Dante Embedded Platform) Container
After=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/home/caster/bumble-auracast/src/dep/dante_package
ExecStart=/bin/bash dep.sh start
ExecStop=/bin/bash dep.sh stop
[Install]
WantedBy=multi-user.target
+2
View File
@@ -9,6 +9,8 @@ ExecStartPre=/bin/sh -lc 'for i in $(seq 1 60); do ip route show default >/dev/n
ExecStart=/usr/bin/pipewire-aes67 -c /home/caster/bumble-auracast/src/service/aes67/pipewire-aes67.conf
Restart=always
RestartSec=5s
AllowedCPUs=0
CPUAffinity=0
# Avoid StartLimitHit on quick failures during boot; let RestartSec handle pacing
StartLimitIntervalSec=0
+2
View File
@@ -6,6 +6,8 @@ After=network.target
Type=simple
ExecStart=/usr/sbin/ptp4l -i eth0 -f /home/caster/bumble-auracast/src/service/aes67/ptp_aes67_1.conf
Restart=on-failure
AllowedCPUs=0
CPUAffinity=0
StandardOutput=journal
StandardError=journal
@@ -8,30 +8,43 @@ set -e
# Enable link-local for all wired ethernet connections
while IFS=: read -r name type; do
if [[ "$type" == *"ethernet"* ]]; then
echo "Enabling IPv4 link-local for connection: $name"
echo "Configuring connection: $name"
# link-local: always enabled so direct-connect (no DHCP) works immediately
sudo nmcli connection modify "$name" ipv4.link-local enabled 2>/dev/null || echo "Failed to modify $name"
# may-fail=yes: do NOT tear down the connection when DHCP times out.
# Without this, NM declares ip-config-unavailable after the 45s DHCP timeout
# and enters a reconnect loop that causes ~1.5 min outages every ~45 seconds.
sudo nmcli connection modify "$name" ipv4.may-fail yes 2>/dev/null || echo "Failed to set may-fail on $name"
# Infinite DHCP timeout: NM keeps retrying DHCP in the background but never
# declares ip-config-unavailable. This prevents the 45s reconnect loop that
# kills the link-local address in direct-connect (no DHCP server) scenarios.
sudo nmcli connection modify "$name" ipv4.dhcp-timeout infinity 2>/dev/null || echo "Failed to set dhcp-timeout on $name"
sudo nmcli connection up "$name" 2>/dev/null || echo "Failed to bring up $name"
fi
done < <(nmcli -t -f NAME,TYPE connection show)
# Configure Avahi to prefer DHCP address over static fallback for mDNS
# Get the DHCP-assigned IP (first non-localhost, non-192.168.42.10 IP)
DHCP_IP=$(ip -4 addr show eth0 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | grep -v '^127\.' | grep -v '^169\.254\.' | head -n1)
HOSTNAME=$(hostname)
if [ -n "$DHCP_IP" ]; then
echo "DHCP address detected: $DHCP_IP, configuring Avahi to prefer it for mDNS."
# Add entry to /etc/avahi/hosts to explicitly map hostname to DHCP IP
sudo mkdir -p /etc/avahi
echo "$DHCP_IP $HOSTNAME $HOSTNAME.local" | sudo tee /etc/avahi/hosts > /dev/null
# Restart avahi to apply the hosts file
sudo systemctl restart avahi-daemon
else
echo "No DHCP address detected, mDNS will use link local"
# Remove hosts file to let Avahi advertise all IPs
sudo rm -f /etc/avahi/hosts
sudo systemctl restart avahi-daemon
fi
# Remove stale avahi hosts pin — this file overrides per-interface advertisement
# and causes mDNS to always resolve to eth0's IP regardless of which interface
# the query arrived on, breaking eth1 mDNS entirely.
sudo rm -f /etc/avahi/hosts
sudo systemctl restart avahi-daemon
# Ensure Loopback is loaded with a fixed name and index
# Needed for dante
# TODO image when we create the next image this should be part of it
echo "options snd-aloop index=6 id=Loopback pcm_substreams=6" | sudo tee /etc/modprobe.d/snd-aloop.conf
echo snd-aloop | sudo tee /etc/modules-load.d/snd-aloop.conf
# Install NetworkManager dispatcher script for link-local / Avahi management
sudo cp /home/caster/bumble-auracast/src/service/10-link-local-mgmt /etc/NetworkManager/dispatcher.d/10-link-local-mgmt
sudo chown root:root /etc/NetworkManager/dispatcher.d/10-link-local-mgmt
sudo chmod 755 /etc/NetworkManager/dispatcher.d/10-link-local-mgmt
# Copy system service file for DEP
sudo cp /home/caster/bumble-auracast/src/service/dep.service /etc/systemd/system/dep.service
# Copy system service file for frontend
sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/systemd/system/auracast-frontend.service
@@ -40,20 +53,25 @@ sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/
mkdir -p /home/caster/.config/systemd/user
cp /home/caster/bumble-auracast/src/service/auracast-server.service /home/caster/.config/systemd/user/auracast-server.service
# Reload systemd for frontend
# Reload systemd for frontend and dep
sudo systemctl daemon-reload
# Reload user systemd for server
systemctl --user daemon-reload
# Enable DEP to start on boot (system)
sudo systemctl enable dep.service
# Enable frontend to start on boot (system)
sudo systemctl enable auracast-frontend.service
# Enable server to start on boot (user)
systemctl --user enable auracast-server.service
# Restart both
# Restart all
sudo systemctl restart dep.service
sudo systemctl restart auracast-frontend.service
systemctl --user restart auracast-server.service
#print status
sudo systemctl status dep.service --no-pager
sudo systemctl status auracast-frontend.service --no-pager
systemctl --user status auracast-server.service --no-pager