Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53fd074d22 | |||
| d1471fae79 | |||
| 50761a4b37 | |||
| 5bb31e3f6a | |||
| edd23fc115 | |||
| 19a01e404c | |||
| efb55050c0 | |||
| c82a17016e | |||
| 6d54e72f1d | |||
| df6c85d9ff | |||
| 8106f61d6a | |||
| 0a8dc74d5c | |||
| 8475e4d068 | |||
| 3f01ef5968 | |||
| 67992e65ec | |||
| 0b12323921 | |||
| 6e633d2880 | |||
| 7bdf6f8417 | |||
| 291d75b137 | |||
| a126613739 | |||
| 036b5f80dd | |||
| e818765b4f | |||
| 3d59a6dabf | |||
| cf69ad2957 | |||
| cdfecaf5eb | |||
| 4036fee1f5 | |||
| 1687a2b790 | |||
| a605195646 | |||
| e1d717ed5c | |||
| 540d8503ac | |||
| c82f375539 | |||
| 70bde5295f | |||
| f5f93b4b8e | |||
| 3322b9edf4 | |||
| d6230e7522 | |||
| f2382470d8 | |||
| 7c2f0bf0cb |
+8
-1
@@ -51,4 +51,11 @@ src/auracast/available_samples.txt
|
||||
src/auracast/server/stream_settings2.json
|
||||
src/scripts/temperature_log*
|
||||
|
||||
src/auracast/server/recordings/
|
||||
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
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
- this projects uses poetry for package management
|
||||
- if something should be run in a python env use 'poetry run'
|
||||
|
||||
# Environment
|
||||
- this application normally runs on an embedded linux on a cm4
|
||||
|
||||
## Application
|
||||
- this is a bluetooth Auracast transmitter application
|
||||
- if you add a new parameter for a stream make sure it is saved to the settings.json so it is persisted
|
||||
- it consists of multicast_frontend.py and multicast_server.py mainly which connect to each other via a rest api
|
||||
|
||||
- after you implemented something the user will mainly test it and you should call the update_and_run_server_and_frontend.sh script if the server and frontend were already running.
|
||||
|
||||
Generated
+33
-46
@@ -270,51 +270,6 @@ optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "av-14.4.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:10219620699a65b9829cfa08784da2ed38371f1a223ab8f3523f440a24c8381c"},
|
||||
{file = "av-14.4.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:8bac981fde1c05e231df9f73a06ed9febce1f03fb0f1320707ac2861bba2567f"},
|
||||
{file = "av-14.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc634ed5bdeb362f0523b73693b079b540418d35d7f3003654f788ae6c317eef"},
|
||||
{file = "av-14.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23973ed5c5bec9565094d2b3643f10a6996707ddffa5252e112d578ad34aa9ae"},
|
||||
{file = "av-14.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0655f7207db6a211d7cedb8ac6a2f7ccc9c4b62290130e393a3fd99425247311"},
|
||||
{file = "av-14.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1edaab73319bfefe53ee09c4b1cf7b141ea7e6678a0a1c62f7bac1e2c68ec4e7"},
|
||||
{file = "av-14.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b54838fa17c031ffd780df07b9962fac1be05220f3c28468f7fe49474f1bf8d2"},
|
||||
{file = "av-14.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f4b59ac6c563b9b6197299944145958a8ec34710799fd851f1a889b0cbcd1059"},
|
||||
{file = "av-14.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a0192a584fae9f6cedfac03c06d5bf246517cdf00c8779bc33414404796a526e"},
|
||||
{file = "av-14.4.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5b21d5586a88b9fce0ab78e26bd1c38f8642f8e2aad5b35e619f4d202217c701"},
|
||||
{file = "av-14.4.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:cf8762d90b0f94a20c9f6e25a94f1757db5a256707964dfd0b1d4403e7a16835"},
|
||||
{file = "av-14.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0ac9f08920c7bbe0795319689d901e27cb3d7870b9a0acae3f26fc9daa801a6"},
|
||||
{file = "av-14.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a56d9ad2afdb638ec0404e962dc570960aae7e08ae331ad7ff70fbe99a6cf40e"},
|
||||
{file = "av-14.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bed513cbcb3437d0ae47743edc1f5b4a113c0b66cdd4e1aafc533abf5b2fbf2"},
|
||||
{file = "av-14.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d030c2d3647931e53d51f2f6e0fcf465263e7acf9ec6e4faa8dbfc77975318c3"},
|
||||
{file = "av-14.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1cc21582a4f606271d8c2036ec7a6247df0831050306c55cf8a905701d0f0474"},
|
||||
{file = "av-14.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce7c9cd452153d36f1b1478f904ed5f9ab191d76db873bdd3a597193290805d4"},
|
||||
{file = "av-14.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd261e31cc6b43ca722f80656c39934199d8f2eb391e0147e704b6226acebc29"},
|
||||
{file = "av-14.4.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:a53e682b239dd23b4e3bc9568cfb1168fc629ab01925fdb2e7556eb426339e94"},
|
||||
{file = "av-14.4.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:5aa0b901751a32703fa938d2155d56ce3faf3630e4a48d238b35d2f7e49e5395"},
|
||||
{file = "av-14.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3b316fed3597675fe2aacfed34e25fc9d5bb0196dc8c0b014ae5ed4adda48de"},
|
||||
{file = "av-14.4.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a587b5c5014c3c0e16143a0f8d99874e46b5d0c50db6111aa0b54206b5687c81"},
|
||||
{file = "av-14.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d53f75e8ac1ec8877a551c0db32a83c0aaeae719d05285281eaaba211bbc30"},
|
||||
{file = "av-14.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c8558cfde79dd8fc92d97c70e0f0fa8c94c7a66f68ae73afdf58598f0fe5e10d"},
|
||||
{file = "av-14.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:455b6410dea0ab2d30234ffb28df7d62ca3cdf10708528e247bec3a4cdcced09"},
|
||||
{file = "av-14.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1661efbe9d975f927b8512d654704223d936f39016fad2ddab00aee7c40f412c"},
|
||||
{file = "av-14.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:fbbeef1f421a3461086853d6464ad5526b56ffe8ccb0ab3fd0a1f121dfbf26ad"},
|
||||
{file = "av-14.4.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3d2aea7c602b105363903e4017103bc4b60336e7aff80e1c22e8b4ec09fd125f"},
|
||||
{file = "av-14.4.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:38c18f036aeb6dc9abf5e867d998c867f9ec93a5f722b60721fdffc123bbb2ae"},
|
||||
{file = "av-14.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58c1e18c8be73b6eada2d9ec397852ec74ebe51938451bdf83644a807189d6c8"},
|
||||
{file = "av-14.4.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4c32ff03a357feb030634f093089a73cb474b04efe7fbfba31f229cb2fab115"},
|
||||
{file = "av-14.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af31d16ae25964a6a02e09cc132b9decd5ee493c5dcb21bcdf0d71b2d6adbd59"},
|
||||
{file = "av-14.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9fb297009e528f4851d25f3bb2781b2db18b59b10aed10240e947b77c582fb7"},
|
||||
{file = "av-14.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:573314cb9eafec2827dc98c416c965330dc7508193adbccd281700d8673b9f0a"},
|
||||
{file = "av-14.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f82ab27ee57c3b80eb50a5293222307dfdc02f810ea41119078cfc85ea3cf9a8"},
|
||||
{file = "av-14.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f682003bbcaac620b52f68ff0e85830fff165dea53949e217483a615993ca20"},
|
||||
{file = "av-14.4.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:8ff683777e0bb3601f7cfb4545dca25db92817585330b773e897e1f6f9d612f7"},
|
||||
{file = "av-14.4.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:fe372acf7b1814bc2b16d89161609db63f81dad88684da76d26dd32cd1c16f92"},
|
||||
{file = "av-14.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de869030eb8acfdfe39f39965de3a899dcde9b08df2db41f183c6166ca6f6d09"},
|
||||
{file = "av-14.4.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9117ed91fba6299b7d5233dd3e471770bab829f97e5a157f182761e9fb59254c"},
|
||||
{file = "av-14.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54e8f9209184098b7755e6250be8ffa48a8aa5b554a02555406120583da17373"},
|
||||
{file = "av-14.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:38ea51e62a014663caec7f621d6601cf269ef450f3c8705f5e3225e5623fd15d"},
|
||||
{file = "av-14.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5d1d89842efe913448482573a253bd6955ce30a77f8a4cd04a1a3537cc919896"},
|
||||
{file = "av-14.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c3048e333da1367a2bca47e69593e10bc70f027d876adee9d1582c8cb818f36a"},
|
||||
{file = "av-14.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:5d6f25570d0782dd05640c7e1f71cb29857d94d915b5521a1e757ecae78a5a50"},
|
||||
{file = "av-14.4.0.tar.gz", hash = "sha256:3ecbf803a7fdf67229c0edada0830d6bfaea4d10bfb24f0c3f4e607cd1064b42"},
|
||||
]
|
||||
|
||||
@@ -1849,6 +1804,22 @@ files = [
|
||||
{file = "protobuf-6.30.2.tar.gz", hash = "sha256:35c859ae076d8c56054c25b59e5e59638d86545ed6e2b6efac6be0b6ea3ba048"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyalsaaudio"
|
||||
version = "0.11.0"
|
||||
description = "ALSA bindings"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = []
|
||||
develop = false
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "ssh://git@gitea.summitwave.work:222/auracaster/sw_pyalsaaudio.git"
|
||||
reference = "b3d11582e03df6929b2e7acbaa1306afc7b8a6bc"
|
||||
resolved_reference = "b3d11582e03df6929b2e7acbaa1306afc7b8a6bc"
|
||||
|
||||
[[package]]
|
||||
name = "pyarrow"
|
||||
version = "20.0.0"
|
||||
@@ -2443,6 +2414,22 @@ files = [
|
||||
{file = "rpds_py-0.25.1.tar.gz", hash = "sha256:8960b6dac09b62dac26e75d7e2c4a22efb835d827a7278c34f72b2b84fa160e3"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpi-gpio"
|
||||
version = "0.7.1"
|
||||
description = "A module to control Raspberry Pi GPIO channels"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "RPi.GPIO-0.7.1-cp27-cp27mu-linux_armv6l.whl", hash = "sha256:b86b66dc02faa5461b443a1e1f0c1d209d64ab5229696f32fb3b0215e0600c8c"},
|
||||
{file = "RPi.GPIO-0.7.1-cp310-cp310-linux_armv6l.whl", hash = "sha256:57b6c044ef5375a78c8dda27cdfadf329e76aa6943cd6cffbbbd345a9adf9ca5"},
|
||||
{file = "RPi.GPIO-0.7.1-cp37-cp37m-linux_armv6l.whl", hash = "sha256:77afb817b81331ce3049a4b8f94a85e41b7c404d8e56b61ac0f1eb75c3120868"},
|
||||
{file = "RPi.GPIO-0.7.1-cp38-cp38-linux_armv6l.whl", hash = "sha256:29226823da8b5ccb9001d795a944f2e00924eeae583490f0bc7317581172c624"},
|
||||
{file = "RPi.GPIO-0.7.1-cp39-cp39-linux_armv6l.whl", hash = "sha256:15311d3b063b71dee738cd26570effc9985a952454d162937c34e08c0fc99902"},
|
||||
{file = "RPi.GPIO-0.7.1.tar.gz", hash = "sha256:cd61c4b03c37b62bba4a5acfea9862749c33c618e0295e7e90aa4713fb373b70"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "samplerate"
|
||||
version = "0.2.2"
|
||||
@@ -2976,4 +2963,4 @@ test = ["pytest", "pytest-asyncio"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11"
|
||||
content-hash = "3c9f92c7a5af40f98da9c7824d9c2a6f7eb809e91e43cfef4995761b2e887256"
|
||||
content-hash = "7bccf2978170ead195e1e8cff151823a5276823195a239622186fcec830154d9"
|
||||
|
||||
+5
-1
@@ -17,7 +17,11 @@ dependencies = [
|
||||
"sounddevice (>=0.5.2,<0.6.0)",
|
||||
"python-dotenv (>=1.1.1,<2.0.0)",
|
||||
"smbus2 (>=0.5.0,<0.6.0)",
|
||||
"samplerate (>=0.2.2,<0.3.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",
|
||||
"vosk (>=0.3.45)",
|
||||
"faster-whisper (>=1.0.0)"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -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,11 +121,12 @@ 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):
|
||||
bigs: List[AuracastBigConfig] = [
|
||||
AuracastBigConfigDeu(),
|
||||
]
|
||||
analog_gain: int = 50 # ADC gain level for analog mode (10-60%)
|
||||
analog_gain_db_left: float = 0.0 # ADC gain level for analog mode left channel (-12 to 18 dB)
|
||||
analog_gain_db_right: float = 0.0 # ADC gain level for analog mode right channel (-12 to 18 dB)
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
+222
-99
@@ -30,6 +30,7 @@ import time
|
||||
import threading
|
||||
|
||||
import numpy as np # for audio down-mix
|
||||
import samplerate
|
||||
import os
|
||||
|
||||
import lc3 # type: ignore # pylint: disable=E0401
|
||||
@@ -48,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
|
||||
@@ -56,7 +58,7 @@ from auracast.utils.webrtc_audio_input import WebRTCAudioInput
|
||||
|
||||
|
||||
# Patch sounddevice.InputStream globally to use low-latency settings
|
||||
import sounddevice as sd
|
||||
import alsaaudio
|
||||
from collections import deque
|
||||
|
||||
|
||||
@@ -139,96 +141,146 @@ class AlsaArecordAudioInput(audio_io.AudioInput):
|
||||
self._proc = None
|
||||
|
||||
|
||||
class ModSoundDeviceAudioInput(audio_io.SoundDeviceAudioInput):
|
||||
"""Patched SoundDeviceAudioInput with low-latency capture and adaptive resampling."""
|
||||
class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
|
||||
"""PyALSA audio input with non-blocking reads - supports mono/stereo."""
|
||||
|
||||
def _open(self):
|
||||
"""Create RawInputStream with low-latency parameters and initialize ring buffer."""
|
||||
dev_info = sd.query_devices(self._device)
|
||||
hostapis = sd.query_hostapis()
|
||||
api_index = dev_info.get('hostapi')
|
||||
api_name = hostapis[api_index]['name'] if isinstance(api_index, int) and 0 <= api_index < len(hostapis) else 'unknown'
|
||||
pa_ver = sd.get_portaudio_version()
|
||||
def __init__(self, device, pcm_format: audio_io.PcmFormat):
|
||||
super().__init__()
|
||||
logging.info("PyALSA: device = %s", device)
|
||||
self._device = str(device) if not isinstance(device, str) else device
|
||||
if self._device.isdigit():
|
||||
self._device = 'default' if self._device == '0' else f'hw:{self._device}'
|
||||
self._pcm_format = pcm_format
|
||||
self._pcm = None
|
||||
self._actual_channels = None
|
||||
self._periodsize = None
|
||||
self._hw_channels = None
|
||||
self._first_read = True
|
||||
self._resampler = None
|
||||
self._resampler_buffer = np.empty(0, dtype=np.float32)
|
||||
|
||||
logging.info(
|
||||
"SoundDevice backend=%s device='%s' (id=%s) ch=%s default_low_input_latency=%.4f default_high_input_latency=%.4f portaudio=%s",
|
||||
api_name,
|
||||
dev_info.get('name'),
|
||||
self._device,
|
||||
dev_info.get('max_input_channels'),
|
||||
float(dev_info.get('default_low_input_latency') or 0.0),
|
||||
float(dev_info.get('default_high_input_latency') or 0.0),
|
||||
pa_ver[1] if isinstance(pa_ver, tuple) and len(pa_ver) >= 2 else pa_ver,
|
||||
)
|
||||
# Create RawInputStream with injected low-latency parameters
|
||||
# Target ~2 ms blocksize (48 kHz -> 96 frames). For other rates, keep ~2 ms.
|
||||
_sr = int(self._pcm_format.sample_rate)
|
||||
|
||||
self.counter=0
|
||||
self.max_avail=0
|
||||
self.logfile_name="available_samples.txt"
|
||||
self.blocksize = 120
|
||||
|
||||
if os.path.exists(self.logfile_name):
|
||||
os.remove(self.logfile_name)
|
||||
|
||||
self._stream = sd.RawInputStream(
|
||||
samplerate=self._pcm_format.sample_rate,
|
||||
def _open(self) -> audio_io.PcmFormat:
|
||||
ALSA_PERIODSIZE = 240
|
||||
ALSA_PERIODS = 4
|
||||
ALSA_MODE = alsaaudio.PCM_NONBLOCK
|
||||
|
||||
requested_rate = int(self._pcm_format.sample_rate)
|
||||
requested_channels = int(self._pcm_format.channels)
|
||||
self._periodsize = ALSA_PERIODSIZE
|
||||
|
||||
self._pcm = alsaaudio.PCM(
|
||||
type=alsaaudio.PCM_CAPTURE,
|
||||
mode=ALSA_MODE,
|
||||
device=self._device,
|
||||
channels=self._pcm_format.channels,
|
||||
dtype='int16',
|
||||
blocksize=self.blocksize,
|
||||
latency=0.004,
|
||||
periods=ALSA_PERIODS,
|
||||
)
|
||||
self._stream.start()
|
||||
|
||||
self._pcm.setchannels(requested_channels)
|
||||
self._pcm.setformat(alsaaudio.PCM_FORMAT_S16_LE)
|
||||
actual_rate = self._pcm.setrate(requested_rate)
|
||||
self._pcm.setperiodsize(ALSA_PERIODSIZE)
|
||||
|
||||
logging.info("PyALSA: device=%s rate=%d ch=%d periodsize=%d (%.1fms) periods=%d mode=%s",
|
||||
self._device, actual_rate, requested_channels, ALSA_PERIODSIZE,
|
||||
(ALSA_PERIODSIZE / actual_rate) * 1000, ALSA_PERIODS, ALSA_MODE)
|
||||
|
||||
if actual_rate != requested_rate:
|
||||
logging.warning("PyALSA: Sample rate mismatch! requested=%d actual=%d", requested_rate, actual_rate)
|
||||
|
||||
self._actual_channels = requested_channels
|
||||
self._resampler = samplerate.Resampler('sinc_fastest', channels=requested_channels)
|
||||
self._resampler_buffer = np.empty(0, dtype=np.float32)
|
||||
|
||||
self._bang_bang = 0
|
||||
|
||||
return audio_io.PcmFormat(
|
||||
audio_io.PcmFormat.Endianness.LITTLE,
|
||||
audio_io.PcmFormat.SampleType.INT16,
|
||||
self._pcm_format.sample_rate,
|
||||
1,
|
||||
actual_rate,
|
||||
requested_channels,
|
||||
)
|
||||
|
||||
def _read(self, frame_size: int) -> bytes:
|
||||
"""Read PCM samples from the stream."""
|
||||
try:
|
||||
avail = self._pcm.avail()
|
||||
logging.debug("PyALSA: avail before read: %d", avail)
|
||||
length, data = self._pcm.read_sw(frame_size + self._bang_bang)
|
||||
avail = self._pcm.avail()
|
||||
SETPOINT = 120
|
||||
TOLERANCE = 80
|
||||
if avail < SETPOINT - TOLERANCE:
|
||||
self._bang_bang = -1
|
||||
elif avail > SETPOINT + TOLERANCE:
|
||||
self._bang_bang = 1
|
||||
else:
|
||||
self._bang_bang = 0
|
||||
|
||||
|
||||
logging.debug("PyALSA: read length=%d, data length=%d, avail=%d, bang_bang=%d", length, len(data), avail, self._bang_bang)
|
||||
|
||||
#if self.counter % 50 == 0:
|
||||
frame_size = frame_size + 1 # consume samples a little faster to avoid latency akkumulation
|
||||
if length > 0:
|
||||
if self._first_read:
|
||||
expected_mono = self._periodsize * 2
|
||||
expected_stereo = self._periodsize * 2 * 2
|
||||
# self._hw_channels = 2 if len(data) == expected_stereo else 1
|
||||
self._hw_channels = self._actual_channels
|
||||
logging.info("PyALSA first read: bytes=%d detected_hw_channels=%d requested_channels=%d",
|
||||
len(data), self._hw_channels, self._actual_channels)
|
||||
self._first_read = False
|
||||
|
||||
if self._hw_channels == 2 and self._actual_channels == 1:
|
||||
pcm_stereo = np.frombuffer(data, dtype=np.int16)
|
||||
pcm_mono = pcm_stereo[::2]
|
||||
data = pcm_mono.tobytes()
|
||||
|
||||
actual_samples = len(data) // (2 * self._actual_channels)
|
||||
ratio = frame_size / actual_samples
|
||||
pcm_f32 = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
if self._actual_channels > 1:
|
||||
pcm_f32 = pcm_f32.reshape(-1, self._actual_channels)
|
||||
resampled = self._resampler.process(pcm_f32, ratio, end_of_input=False)
|
||||
if self._actual_channels > 1:
|
||||
resampled = resampled.reshape(-1)
|
||||
self._resampler_buffer = np.concatenate([self._resampler_buffer, resampled])
|
||||
else:
|
||||
logging.warning("PyALSA: No data read from ALSA")
|
||||
self._resampler_buffer = np.concatenate([
|
||||
self._resampler_buffer,
|
||||
np.zeros(frame_size * self._actual_channels, dtype=np.float32),
|
||||
])
|
||||
except alsaaudio.ALSAAudioError as e:
|
||||
logging.error("PyALSA: ALSA read error: %s", e)
|
||||
self._resampler_buffer = np.concatenate([
|
||||
self._resampler_buffer,
|
||||
np.zeros(frame_size * self._actual_channels, dtype=np.float32),
|
||||
])
|
||||
except Exception as e:
|
||||
logging.error("PyALSA: Unexpected error in _read: %s", e, exc_info=True)
|
||||
self._resampler_buffer = np.concatenate([
|
||||
self._resampler_buffer,
|
||||
np.zeros(frame_size * self._actual_channels, dtype=np.float32),
|
||||
])
|
||||
|
||||
pcm_buffer, overflowed = self._stream.read(frame_size)
|
||||
if overflowed:
|
||||
logging.warning("SoundDeviceAudioInput: overflowed")
|
||||
needed = frame_size * self._actual_channels
|
||||
if len(self._resampler_buffer) < needed:
|
||||
pad = np.zeros(needed - len(self._resampler_buffer), dtype=np.float32)
|
||||
self._resampler_buffer = np.concatenate([self._resampler_buffer, pad])
|
||||
logging.debug("PyALSA: padded buffer with %d samples", needed - len(self._resampler_buffer))
|
||||
|
||||
n_available = self._stream.read_available
|
||||
output = self._resampler_buffer[:needed]
|
||||
self._resampler_buffer = self._resampler_buffer[needed:]
|
||||
|
||||
# adapt = n_available > 20
|
||||
# if adapt:
|
||||
# pcm_extra, overflowed = self._stream.read(3)
|
||||
# logging.info('consuming extra samples, available was %d', n_available)
|
||||
# if overflowed:
|
||||
# logging.warning("SoundDeviceAudioInput: overflowed")
|
||||
|
||||
# out = bytes(pcm_buffer) + bytes(pcm_extra)
|
||||
# else:
|
||||
out = bytes(pcm_buffer)
|
||||
logging.debug("PyALSA: resampler_buffer remaining=%d", len(self._resampler_buffer))
|
||||
return np.clip(output * 32767.0, -32768, 32767).astype(np.int16).tobytes()
|
||||
|
||||
self.max_avail = max(self.max_avail, n_available)
|
||||
|
||||
|
||||
#Diagnostics
|
||||
#with open(self.logfile_name, "a", encoding="utf-8") as f:
|
||||
# f.write(f"{n_available}, {adapt}, {round(self._runavg, 2)}, {overflowed}\n")
|
||||
def _close(self) -> None:
|
||||
if self._pcm:
|
||||
self._pcm.close()
|
||||
self._pcm = None
|
||||
|
||||
if self.counter % 500 == 0:
|
||||
logging.info(
|
||||
"read available=%d, max=%d, latency:%d",
|
||||
n_available, self.max_avail, self._stream.latency
|
||||
)
|
||||
self.max_avail = 0
|
||||
|
||||
self.counter += 1
|
||||
return out
|
||||
|
||||
audio_io.SoundDeviceAudioInput = ModSoundDeviceAudioInput
|
||||
audio_io.SoundDeviceAudioInput = PyAlsaAudioInput
|
||||
|
||||
# modified from bumble
|
||||
class ModWaveAudioInput(audio_io.ThreadedAudioInput):
|
||||
@@ -411,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
|
||||
@@ -468,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,
|
||||
@@ -485,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()
|
||||
@@ -538,7 +626,7 @@ async def init_broadcast(
|
||||
|
||||
def on_flow():
|
||||
data_packet_queue = iso_queue.data_packet_queue
|
||||
print(
|
||||
logging.info(
|
||||
f'\rPACKETS: pending={data_packet_queue.pending}, '
|
||||
f'queued={data_packet_queue.queued}, '
|
||||
f'completed={data_packet_queue.completed}',
|
||||
@@ -551,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
|
||||
@@ -638,6 +749,12 @@ class Streamer():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_audio_levels(self) -> list[float]:
|
||||
"""Return current RMS audio levels (0.0-1.0) for each BIG."""
|
||||
if not self.bigs:
|
||||
return []
|
||||
return [big.get('_audio_level_rms', 0.0) for big in self.bigs.values()]
|
||||
|
||||
async def stream(self):
|
||||
|
||||
bigs = self.bigs
|
||||
@@ -700,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'):
|
||||
@@ -754,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
|
||||
@@ -823,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')
|
||||
@@ -852,6 +970,11 @@ class Streamer():
|
||||
stream_finished[i] = True
|
||||
continue
|
||||
|
||||
# Compute RMS audio level (normalized 0.0-1.0) for level monitoring
|
||||
pcm_samples = np.frombuffer(pcm_frame, dtype=np.int16).astype(np.float32)
|
||||
rms = np.sqrt(np.mean(pcm_samples ** 2)) / 32768.0 if len(pcm_samples) > 0 else 0.0
|
||||
big['_audio_level_rms'] = float(rms)
|
||||
|
||||
# Measure LC3 encoding time
|
||||
t1 = time.perf_counter()
|
||||
num_bis = big.get('num_bis', 1)
|
||||
|
||||
@@ -37,6 +37,12 @@ class Multicaster:
|
||||
'is_initialized': self.is_auracast_init,
|
||||
'is_streaming': streaming,
|
||||
}
|
||||
|
||||
def get_audio_levels(self) -> list[float]:
|
||||
"""Return current RMS audio levels (0.0-1.0) for each BIG."""
|
||||
if self.streamer is not None and self.streamer.is_streaming:
|
||||
return self.streamer.get_audio_levels()
|
||||
return []
|
||||
|
||||
async def init_broadcast(self):
|
||||
self.device_acm = multicast.create_device(self.global_conf)
|
||||
@@ -137,6 +143,10 @@ async def main():
|
||||
level=os.environ.get('LOG_LEVEL', logging.DEBUG),
|
||||
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
|
||||
)
|
||||
|
||||
# Enable debug logging for bumble
|
||||
# logging.getLogger('bumble').setLevel(logging.DEBUG)
|
||||
|
||||
os.chdir(os.path.dirname(__file__))
|
||||
|
||||
global_conf = auracast_config.AuracastGlobalConfig(
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Minimal HTTP server that redirects all requests to HTTPS (port 443).
|
||||
|
||||
Run on port 80 alongside the HTTPS Streamlit frontend so that users who
|
||||
type a bare IP address into their browser are automatically forwarded.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import sys
|
||||
|
||||
|
||||
class RedirectHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
host = self.headers.get("Host", "").split(":")[0] or self.server.server_address[0]
|
||||
target = f"https://{host}{self.path}"
|
||||
self.send_response(301)
|
||||
self.send_header("Location", target)
|
||||
self.end_headers()
|
||||
|
||||
# Handle every method the same way
|
||||
do_POST = do_GET
|
||||
do_PUT = do_GET
|
||||
do_DELETE = do_GET
|
||||
do_HEAD = do_GET
|
||||
|
||||
def log_message(self, format, *args):
|
||||
# Keep logging minimal
|
||||
sys.stderr.write(f"[http-redirect] {self.address_string()} -> https {args[0] if args else ''}\n")
|
||||
|
||||
|
||||
def main():
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 80
|
||||
server = http.server.HTTPServer(("0.0.0.0", port), RedirectHandler)
|
||||
print(f"HTTP->HTTPS redirect server listening on 0.0.0.0:{port}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -33,5 +33,12 @@ echo "Using Avahi domain: $AVAHI_DOMAIN"
|
||||
# Path to poetry binary
|
||||
POETRY_BIN="/home/caster/.local/bin/poetry"
|
||||
|
||||
# Start HTTP->HTTPS redirect server on port 80 (background)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
python3 "$SCRIPT_DIR/http_to_https_redirect.py" 80 &
|
||||
REDIRECT_PID=$!
|
||||
echo "HTTP->HTTPS redirect server started (PID $REDIRECT_PID)"
|
||||
trap "kill $REDIRECT_PID 2>/dev/null" EXIT
|
||||
|
||||
# Start Streamlit HTTPS server (port 443)
|
||||
$POETRY_BIN run streamlit run multicast_frontend.py --server.port 443 --server.address 0.0.0.0 --server.enableCORS false --server.enableXsrfProtection false --server.headless true --server.sslCertFile "$CERT" --server.sslKeyFile "$KEY" --browser.gatherUsageStats false
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
# system_update.sh - Runs after git checkout in the Python system_update endpoint.
|
||||
# Called with the current working directory = project root.
|
||||
# All output is also written to /tmp/system_update.log for debugging.
|
||||
|
||||
exec > >(tee -a /tmp/system_update.log) 2>&1
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
POETRY="$HOME/.local/bin/poetry"
|
||||
OPENOCD_SRC="$HOME/sw_openocd"
|
||||
OPENOCD_REPO="ssh://git@gitea.summitwave.work:222/auracaster/sw_openocd.git"
|
||||
OPENOCD_BRANCH="change-8818"
|
||||
OPENOCD_MARKER="$OPENOCD_SRC/.last_built_commit"
|
||||
OPENOCD_DIR="$PROJECT_ROOT/src/openocd"
|
||||
|
||||
echo "[system_update] Starting post-checkout update. project_root=$PROJECT_ROOT"
|
||||
|
||||
# 1. poetry install
|
||||
echo "[system_update] Running poetry install..."
|
||||
(cd "$PROJECT_ROOT" && "$POETRY" install)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[system_update] ERROR: poetry install failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Clone/update and build sw_openocd if needed
|
||||
if [ ! -d "$OPENOCD_SRC" ]; then
|
||||
echo "[system_update] Installing sw_openocd build dependencies..."
|
||||
sudo apt install -y git build-essential libtool autoconf texinfo \
|
||||
libusb-1.0-0-dev libftdi1-dev libhidapi-dev pkg-config || \
|
||||
echo "[system_update] WARNING: apt install deps had errors, continuing"
|
||||
sudo apt-get install -y pkg-config libjim-dev || \
|
||||
echo "[system_update] WARNING: apt-get install libjim-dev had errors, continuing"
|
||||
|
||||
echo "[system_update] Cloning sw_openocd branch $OPENOCD_BRANCH..."
|
||||
git clone --branch "$OPENOCD_BRANCH" --single-branch "$OPENOCD_REPO" "$OPENOCD_SRC"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[system_update] ERROR: git clone sw_openocd failed"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "[system_update] Updating sw_openocd..."
|
||||
git -C "$OPENOCD_SRC" fetch origin "$OPENOCD_BRANCH"
|
||||
git -C "$OPENOCD_SRC" checkout "$OPENOCD_BRANCH"
|
||||
git -C "$OPENOCD_SRC" pull
|
||||
fi
|
||||
|
||||
OPENOCD_COMMIT=$(git -C "$OPENOCD_SRC" rev-parse HEAD)
|
||||
LAST_BUILT=""
|
||||
[ -f "$OPENOCD_MARKER" ] && LAST_BUILT=$(cat "$OPENOCD_MARKER")
|
||||
|
||||
if [ "$OPENOCD_COMMIT" != "$LAST_BUILT" ]; then
|
||||
echo "[system_update] Building sw_openocd (commit $OPENOCD_COMMIT)..."
|
||||
|
||||
(cd "$OPENOCD_SRC" && ./bootstrap)
|
||||
if [ $? -ne 0 ]; then echo "[system_update] ERROR: openocd bootstrap failed"; exit 1; fi
|
||||
|
||||
(cd "$OPENOCD_SRC" && ./configure --enable-bcm2835gpio --enable-sysfsgpio)
|
||||
if [ $? -ne 0 ]; then echo "[system_update] ERROR: openocd configure failed"; exit 1; fi
|
||||
|
||||
(cd "$OPENOCD_SRC" && make)
|
||||
if [ $? -ne 0 ]; then echo "[system_update] ERROR: openocd make failed"; exit 1; fi
|
||||
|
||||
(cd "$OPENOCD_SRC" && sudo make install)
|
||||
if [ $? -ne 0 ]; then echo "[system_update] ERROR: openocd make install failed"; exit 1; fi
|
||||
|
||||
echo "$OPENOCD_COMMIT" > "$OPENOCD_MARKER"
|
||||
echo "[system_update] sw_openocd built and installed (commit $OPENOCD_COMMIT)"
|
||||
else
|
||||
echo "[system_update] sw_openocd up to date (commit $OPENOCD_COMMIT), skipping build"
|
||||
fi
|
||||
|
||||
# 3. Flash firmware to both SWD interfaces
|
||||
FLASH_SCRIPT="$OPENOCD_DIR/flash.sh"
|
||||
HEX_FILE="$OPENOCD_DIR/merged.hex"
|
||||
|
||||
for IFACE in swd0 swd1; do
|
||||
echo "[system_update] Flashing $IFACE..."
|
||||
(cd "$OPENOCD_DIR" && bash "$FLASH_SCRIPT" -i "$IFACE" -f "$HEX_FILE")
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[system_update] ERROR: flash $IFACE failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "[system_update] Flash $IFACE complete"
|
||||
done
|
||||
|
||||
# 4. Restart services (this will kill this process too)
|
||||
echo "[system_update] Restarting services..."
|
||||
bash "$PROJECT_ROOT/src/service/update_and_run_server_and_frontend.sh"
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+71
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DCSubtitle Version="1.0">
|
||||
<SubtitleID>a1b2c3d4-e5f6-7890-abcd-ef1234567890</SubtitleID>
|
||||
<MovieTitle>Sample TextCast Subtitles</MovieTitle>
|
||||
<ReelNumber>1</ReelNumber>
|
||||
<Language>en</Language>
|
||||
<LoadFont Id="Font1" URI="Arial.ttf"/>
|
||||
<Font Id="Font1" Color="FFFFFFFF" Effect="none" Size="42" Italic="no">
|
||||
|
||||
<Subtitle SpotNumber="1" TimeIn="00:00:02:00" TimeOut="00:00:05:00" FadeUpTime="0" FadeDownTime="0">
|
||||
<Text HAlign="center" VAlign="bottom">Welcome to TextCast.</Text>
|
||||
</Subtitle>
|
||||
|
||||
<Subtitle SpotNumber="2" TimeIn="00:00:06:00" TimeOut="00:00:09:00" FadeUpTime="0" FadeDownTime="0">
|
||||
<Text HAlign="center" VAlign="bottom">Text transmitted over Auracast BLE.</Text>
|
||||
</Subtitle>
|
||||
|
||||
<Subtitle SpotNumber="3" TimeIn="00:00:10:00" TimeOut="00:00:13:00" FadeUpTime="0" FadeDownTime="0">
|
||||
<Text HAlign="center" VAlign="bottom">No LC3 audio codec involved.</Text>
|
||||
</Subtitle>
|
||||
|
||||
<Subtitle SpotNumber="4" TimeIn="00:00:14:00" TimeOut="00:00:17:00" FadeUpTime="0" FadeDownTime="0">
|
||||
<Text HAlign="center" VAlign="bottom">Raw ISO SDUs carry UTF-8 text.</Text>
|
||||
</Subtitle>
|
||||
|
||||
<Subtitle SpotNumber="5" TimeIn="00:00:18:00" TimeOut="00:00:21:00" FadeUpTime="0" FadeDownTime="0">
|
||||
<Text HAlign="center" VAlign="bottom">100 frames per second at 40 bytes.</Text>
|
||||
</Subtitle>
|
||||
|
||||
<Subtitle SpotNumber="6" TimeIn="00:00:22:00" TimeOut="00:00:25:00" FadeUpTime="0" FadeDownTime="0">
|
||||
<Text HAlign="center" VAlign="bottom">Scrolling display on SH1106 OLED.</Text>
|
||||
</Subtitle>
|
||||
|
||||
<Subtitle SpotNumber="7" TimeIn="00:00:26:00" TimeOut="00:00:29:00" FadeUpTime="0" FadeDownTime="0">
|
||||
<Text HAlign="center" VAlign="bottom">Each new line scrolls up the screen.</Text>
|
||||
</Subtitle>
|
||||
|
||||
<Subtitle SpotNumber="8" TimeIn="00:00:30:00" TimeOut="00:00:33:00" FadeUpTime="0" FadeDownTime="0">
|
||||
<Text HAlign="center" VAlign="bottom">The quick brown fox jumps over</Text>
|
||||
</Subtitle>
|
||||
|
||||
<Subtitle SpotNumber="9" TimeIn="00:00:34:00" TimeOut="00:00:37:00" FadeUpTime="0" FadeDownTime="0">
|
||||
<Text HAlign="center" VAlign="bottom">the lazy dog.</Text>
|
||||
</Subtitle>
|
||||
|
||||
<Subtitle SpotNumber="10" TimeIn="00:00:38:00" TimeOut="00:00:41:00" FadeUpTime="0" FadeDownTime="0">
|
||||
<Text HAlign="center" VAlign="bottom">Speech-to-text output goes here.</Text>
|
||||
</Subtitle>
|
||||
|
||||
<Subtitle SpotNumber="11" TimeIn="00:00:42:00" TimeOut="00:00:45:00" FadeUpTime="0" FadeDownTime="0">
|
||||
<Text HAlign="center" VAlign="bottom">Latency is dominated by BLE BIG.</Text>
|
||||
</Subtitle>
|
||||
|
||||
<Subtitle SpotNumber="12" TimeIn="00:00:46:00" TimeOut="00:00:49:00" FadeUpTime="0" FadeDownTime="0">
|
||||
<Text HAlign="center" VAlign="bottom">Typical end-to-end: under 50 ms.</Text>
|
||||
</Subtitle>
|
||||
|
||||
<Subtitle SpotNumber="13" TimeIn="00:00:50:00" TimeOut="00:00:53:00" FadeUpTime="0" FadeDownTime="0">
|
||||
<Text HAlign="center" VAlign="bottom">One transmitter, many receivers.</Text>
|
||||
</Subtitle>
|
||||
|
||||
<Subtitle SpotNumber="14" TimeIn="00:00:54:00" TimeOut="00:00:57:00" FadeUpTime="0" FadeDownTime="0">
|
||||
<Text HAlign="center" VAlign="bottom">Built on Bumble and Zephyr RTOS.</Text>
|
||||
</Subtitle>
|
||||
|
||||
<Subtitle SpotNumber="15" TimeIn="00:00:58:00" TimeOut="00:01:01:00" FadeUpTime="0" FadeDownTime="0">
|
||||
<Text HAlign="center" VAlign="bottom">End of demonstration. Thank you.</Text>
|
||||
</Subtitle>
|
||||
|
||||
</Font>
|
||||
</DCSubtitle>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
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.
BIN
Binary file not shown.
BIN
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.
BIN
Binary file not shown.
BIN
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.
BIN
Binary file not shown.
BIN
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.
BIN
Binary file not shown.
BIN
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.
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ pcm.ch1 {
|
||||
channels 2
|
||||
rate 48000
|
||||
format S16_LE
|
||||
period_size 120
|
||||
buffer_size 240
|
||||
period_size 240
|
||||
buffer_size 960
|
||||
}
|
||||
bindings.0 0
|
||||
}
|
||||
@@ -21,8 +21,8 @@ pcm.ch2 {
|
||||
channels 2
|
||||
rate 48000
|
||||
format S16_LE
|
||||
period_size 120
|
||||
buffer_size 240
|
||||
period_size 240
|
||||
buffer_size 960
|
||||
}
|
||||
bindings.0 1
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
sudo cp src/misc/asound.conf /etc/asound.conf
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
sudo cp "$SCRIPT_DIR/asound.conf" /etc/asound.conf
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
INTERFACE="swd0"
|
||||
HEX_FILE=""
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 -f <hex_file> [-i swd0|swd1]"
|
||||
exit 1
|
||||
}
|
||||
|
||||
while getopts "f:i:h" opt; do
|
||||
case "$opt" in
|
||||
f) HEX_FILE="$OPTARG" ;;
|
||||
i)
|
||||
if [[ "$OPTARG" == "swd0" || "$OPTARG" == "swd1" ]]; then
|
||||
INTERFACE="$OPTARG"
|
||||
else
|
||||
usage
|
||||
fi
|
||||
;;
|
||||
h) usage ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$HEX_FILE" ]] || usage
|
||||
[[ -f "$HEX_FILE" ]] || { echo "HEX file not found: $HEX_FILE"; exit 1; }
|
||||
|
||||
sudo openocd \
|
||||
-f ./raspberrypi-${INTERFACE}.cfg \
|
||||
-c "init" \
|
||||
-c "reset init" \
|
||||
-c "flash banks" \
|
||||
-c "flash write_image $HEX_FILE" \
|
||||
-c "verify_image $HEX_FILE" \
|
||||
-c "reset run" \
|
||||
-c "shutdown"
|
||||
|
||||
sudo openocd \
|
||||
-f ./raspberrypi-${INTERFACE}.cfg \
|
||||
-c "init" \
|
||||
-c "nrf54l.dap apreg 2 0x000 0x1" \
|
||||
-c "sleep 100" \
|
||||
-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."
|
||||
+13168
File diff suppressed because it is too large
Load Diff
@@ -5,4 +5,9 @@ adapter gpio swdio 26
|
||||
#adapter gpio trst 26
|
||||
#reset_config trst_only
|
||||
|
||||
|
||||
source [find target/nordic/nrf54l.cfg]
|
||||
|
||||
flash bank $_CHIPNAME.flash nrf54 0x00000000 0 0 0 $_TARGETNAME
|
||||
|
||||
adapter speed 1000
|
||||
|
||||
@@ -5,4 +5,9 @@ adapter gpio swdio 24
|
||||
#adapter gpio trst 27
|
||||
#reset_config trst_only
|
||||
|
||||
|
||||
source [find target/nordic/nrf54l.cfg]
|
||||
|
||||
flash bank $_CHIPNAME.flash nrf54 0x00000000 0 0 0 $_TARGETNAME
|
||||
|
||||
adapter speed 1000
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Regular → Executable
+49
-2
@@ -4,6 +4,48 @@ set -e
|
||||
# This script installs, enables, and restarts the auracast-server and auracast-frontend services
|
||||
# Requires sudo privileges
|
||||
|
||||
# Ensure static link local is activated (for direct laptop connection)
|
||||
# Enable link-local for all wired ethernet connections
|
||||
while IFS=: read -r name type; do
|
||||
if [[ "$type" == *"ethernet"* ]]; then
|
||||
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)
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
@@ -11,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
|
||||
|
||||
Reference in New Issue
Block a user