webatem 2.1.6__tar.gz → 2.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {webatem-2.1.6 → webatem-2.2.0}/PKG-INFO +1 -1
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/control/commands.py +86 -7
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/control/consumer.py +147 -32
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/css/atem_control.css +13 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/atem_control.js +27 -2
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_fairlight.html +49 -14
- {webatem-2.1.6 → webatem-2.2.0}/pyproject.toml +1 -1
- {webatem-2.1.6 → webatem-2.2.0}/webatem.egg-info/PKG-INFO +1 -1
- {webatem-2.1.6 → webatem-2.2.0}/LICENSE +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/README.md +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/__init__.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/activity.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/apps.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/control/__init__.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/control/activity.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/control/device_api.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/control/logging.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/control/views.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/discovery.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/hooks.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/hyperdeck/__init__.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/hyperdeck/connection.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/hyperdeck/views.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/ip_upload_lock.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/media_pool/__init__.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/media_pool/broadcast.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/media_pool/still_cache.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/media_pool/views.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/media_pool/watcher.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/migrations/0001_initial.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/migrations/0002_legacy_log.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/migrations/__init__.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/models.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/netutil.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/profile/__init__.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/profile/dialog.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/profile/export.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/profile/views.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/routing.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/sightings.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/apple-touch-icon.png +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/brand.css +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/favicon-32.png +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/favicon.ico +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/Archivo-400-latin-ext.woff2 +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/Archivo-400-latin.woff2 +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/Archivo-500-latin-ext.woff2 +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/Archivo-500-latin.woff2 +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/Archivo-600-latin-ext.woff2 +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/Archivo-600-latin.woff2 +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/Archivo-800-latin-ext.woff2 +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/Archivo-800-latin.woff2 +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/IBMPlexMono-400-latin-ext.woff2 +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/IBMPlexMono-400-latin.woff2 +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/IBMPlexMono-500-latin-ext.woff2 +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/IBMPlexMono-500-latin.woff2 +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/IBMPlexMono-600-latin-ext.woff2 +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/IBMPlexMono-600-latin.woff2 +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/OFL-Archivo.txt +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/OFL-IBMPlexMono.txt +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/icon-192.png +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/icon-512.png +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/icon-maskable-512.png +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/manifest.webmanifest +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/mark.svg +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/tray/linux-24.png +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/tray/mac-22.png +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/tray/mac-44.png +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/tray/win-16.png +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/tray/win-32.png +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/css/atem_connect.css +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/icon.png +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/atem_connect.js +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/atem_control_page.js +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/atem_discovery.js +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/atem_hyperdeck.js +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/atem_profile.js +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/atem_realtime_slider.js +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/custom-scrollbar.js +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/recent_label.js +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/wa-select.js +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/vendor/README.md +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/vendor/alpine.min.js +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/vendor/alpinejs-collapse-3.14.3.min.js +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/vendor/bootstrap-icons/bootstrap-icons.min.css +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/vendor/webatem.css +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/storage.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/tally.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/connect.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_atem_info.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_fade_to_black.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_header.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_hyperdeck_panel.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_macros.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_media_pool.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_profile_modal.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_settings_dsk.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_settings_transition.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_settings_usk.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_slider.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_transition_controls.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_transition_style.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_usk_fly.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_usk_keyframe.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_usk_mask.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_usk_slider.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templatetags/__init__.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/templatetags/static_v.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/uploader.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/atem_control/urls.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/setup.cfg +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem/__init__.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem/__main__.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem/applog.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem/asgi.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem/context.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem/launcher.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem/server.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem/settings.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem/templates/base.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem/templates/brand/_mark.svg +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem/templates/launcher.html +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem/uninstall.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem/updates.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem/urls.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem/views.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem/websocket.py +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem.egg-info/SOURCES.txt +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem.egg-info/dependency_links.txt +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem.egg-info/entry_points.txt +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem.egg-info/requires.txt +0 -0
- {webatem-2.1.6 → webatem-2.2.0}/webatem.egg-info/top_level.txt +0 -0
|
@@ -13,7 +13,13 @@ explicit guard against silent miss-imports — a new feature module must be
|
|
|
13
13
|
added there to surface its ops through the dispatch table.
|
|
14
14
|
|
|
15
15
|
Dispatch contract preserved for the consumer:
|
|
16
|
-
dispatch(connection, command: str, data: dict
|
|
16
|
+
dispatch(connection, command: str, data: dict, mixer=None)
|
|
17
|
+
-> (ok: bool, error: Optional[str])
|
|
18
|
+
|
|
19
|
+
``mixer`` is the ``audio.mixer`` of the state last pushed for this
|
|
20
|
+
connection (``'fairlight'``, ``'legacy'`` or ``'unknown'``; absent with
|
|
21
|
+
bmdwire before 1.6). Only ``'legacy'`` changes anything: the audio verbs
|
|
22
|
+
go to the classic mixer (``LEGACY_AUDIO_COMMAND_MAP``).
|
|
17
23
|
|
|
18
24
|
On ok=False the consumer forwards ``error`` as the frontend message text.
|
|
19
25
|
Operations that raise on invalid args are caught here and mapped to the
|
|
@@ -25,8 +31,9 @@ from types import SimpleNamespace
|
|
|
25
31
|
from typing import Tuple, Optional
|
|
26
32
|
|
|
27
33
|
from atemwire.messages import (
|
|
28
|
-
color_generator, downstream_keyer, fade_to_black, fairlight,
|
|
29
|
-
input_video, macros, media, switching, transition,
|
|
34
|
+
audio_legacy, color_generator, downstream_keyer, fade_to_black, fairlight,
|
|
35
|
+
hyperdeck, input_video, macros, media, switching, transition,
|
|
36
|
+
upstream_keyer,
|
|
30
37
|
)
|
|
31
38
|
|
|
32
39
|
|
|
@@ -37,6 +44,7 @@ from atemwire.messages import (
|
|
|
37
44
|
_OPS_MODULES = (
|
|
38
45
|
switching, color_generator, fade_to_black, media, input_video,
|
|
39
46
|
upstream_keyer, macros, downstream_keyer, transition, fairlight, hyperdeck,
|
|
47
|
+
audio_legacy,
|
|
40
48
|
)
|
|
41
49
|
|
|
42
50
|
ops = SimpleNamespace()
|
|
@@ -452,13 +460,84 @@ ATEM_COMMAND_MAP = {
|
|
|
452
460
|
}
|
|
453
461
|
|
|
454
462
|
|
|
455
|
-
|
|
463
|
+
# =========================================================================
|
|
464
|
+
# Classic (legacy, pre-Fairlight) audio mixer
|
|
465
|
+
# =========================================================================
|
|
466
|
+
# A switcher whose state says ``audio.mixer == 'legacy'`` (Production
|
|
467
|
+
# Studio 4K, Television Studio HD and their kin) gets these in place of the
|
|
468
|
+
# Fairlight entries: the frontend sends the same verbs to either mixer. The
|
|
469
|
+
# volume (dB, ``null`` = silenced) and the pan (±100) go through atemwire's
|
|
470
|
+
# legacy_db_to_volume / legacy_pan_to_balance; ``channel`` is ignored, every
|
|
471
|
+
# classic strip being stereo. Every other set_audio_* verb is refused on
|
|
472
|
+
# this mixer, which has no EQ, dynamics, delay or input gain. Built from the
|
|
473
|
+
# protocol docs; untested on a legacy switcher.
|
|
474
|
+
|
|
475
|
+
_LEGACY_MIX_OPTIONS = {
|
|
476
|
+
'Off': {'on': False},
|
|
477
|
+
'On': {'on': True},
|
|
478
|
+
'AudioFollowVideo': {'afv': True},
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _legacy_mix_option(mix_type):
|
|
483
|
+
if mix_type not in _LEGACY_MIX_OPTIONS:
|
|
484
|
+
raise ValueError(f"unknown mix_type {mix_type!r}")
|
|
485
|
+
return _LEGACY_MIX_OPTIONS[mix_type]
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
LEGACY_AUDIO_COMMAND_MAP = {
|
|
489
|
+
'set_audio_strip_volume': lambda c, d: ops.set_audio_input(
|
|
490
|
+
c, source=int(d['source']),
|
|
491
|
+
volume=ops.legacy_db_to_volume(d.get('volume_db'))),
|
|
492
|
+
'set_audio_strip_pan': lambda c, d: ops.set_audio_input(
|
|
493
|
+
c, source=int(d['source']),
|
|
494
|
+
balance=ops.legacy_pan_to_balance(d['pan'])),
|
|
495
|
+
'set_audio_strip_mix_type': lambda c, d: ops.set_audio_input(
|
|
496
|
+
c, source=int(d['source']), **_legacy_mix_option(d['mix_type'])),
|
|
497
|
+
'set_audio_master_volume': lambda c, d: ops.set_audio_master(
|
|
498
|
+
c, volume=ops.legacy_db_to_volume(d.get('volume_db'))),
|
|
499
|
+
'set_audio_master_afv': lambda c, d: ops.set_audio_master(
|
|
500
|
+
c, afv=bool(d['afv'])),
|
|
501
|
+
# The monitor bus, which the state shows as the headphones. Fairlight's
|
|
502
|
+
# headphones (FMHP) have no command in atemwire, so these two are
|
|
503
|
+
# classic-only.
|
|
504
|
+
'set_audio_headphones_volume': lambda c, d: ops.set_audio_monitor(
|
|
505
|
+
c, volume=ops.legacy_db_to_volume(d.get('volume_db'))),
|
|
506
|
+
'set_audio_headphones_mute': lambda c, d: ops.set_audio_monitor(
|
|
507
|
+
c, mute=bool(d['mute'])),
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
_LEGACY_ONLY_AUDIO = frozenset({'set_audio_headphones_volume',
|
|
511
|
+
'set_audio_headphones_mute'})
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _route(command: str, mixer):
|
|
515
|
+
"""``(handler, None)`` for the command on this mixer, or
|
|
516
|
+
``(None, error)``."""
|
|
517
|
+
if mixer == 'legacy':
|
|
518
|
+
handler = LEGACY_AUDIO_COMMAND_MAP.get(command)
|
|
519
|
+
if handler is not None:
|
|
520
|
+
return handler, None
|
|
521
|
+
if command.startswith('set_audio_'):
|
|
522
|
+
return None, (f"{command}: this switcher's classic audio mixer has "
|
|
523
|
+
f"no EQ, dynamics, delay or input gain")
|
|
524
|
+
elif command in _LEGACY_ONLY_AUDIO:
|
|
525
|
+
return None, (f"{command}: the headphones can be set only on a classic "
|
|
526
|
+
f"audio mixer; on Fairlight they are read-only here")
|
|
527
|
+
handler = ATEM_COMMAND_MAP.get(command)
|
|
528
|
+
if handler is None:
|
|
529
|
+
return None, f"unknown command: {command}"
|
|
530
|
+
return handler, None
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def dispatch(connection, command: str, data: dict, mixer=None) -> HandlerResult:
|
|
456
534
|
"""Look up the handler for a command name and execute it. Returns
|
|
457
535
|
``(ok, error)`` tuple. Consumer treats ok=False as a failed command and
|
|
458
|
-
emits ``error`` back to the frontend.
|
|
459
|
-
|
|
536
|
+
emits ``error`` back to the frontend. ``mixer`` is the state's
|
|
537
|
+
``audio.mixer``; see the module docstring."""
|
|
538
|
+
handler, error = _route(command, mixer)
|
|
460
539
|
if handler is None:
|
|
461
|
-
return False,
|
|
540
|
+
return False, error
|
|
462
541
|
try:
|
|
463
542
|
handler(connection, data)
|
|
464
543
|
return True, None
|
|
@@ -16,6 +16,7 @@ from channels.generic.websocket import AsyncWebsocketConsumer
|
|
|
16
16
|
from asgiref.sync import sync_to_async
|
|
17
17
|
from atem_control.media_pool import watcher as media_pool_service
|
|
18
18
|
from atemwire.state import ATEMStateMixin
|
|
19
|
+
from atemwire.messages.audio_legacy import enable_audio_levels
|
|
19
20
|
from atemwire.messages.fairlight import enable_fairlight_levels
|
|
20
21
|
from atemwire.messages.system_info import device_name
|
|
21
22
|
from atemwire.pool import ATEMInstanceManager
|
|
@@ -141,6 +142,12 @@ class ATEMConsumer(ATEMConnectionLoggingMixin, ATEMStateMixin, AsyncWebsocketCon
|
|
|
141
142
|
# worker died, builds a new protocol; the poll loop's identity
|
|
142
143
|
# check re-registers the handlers on it.
|
|
143
144
|
self._meter_protocol = None
|
|
145
|
+
# Which mixer's meters are armed: 'fairlight' (SFLN, FMLv/FDLv) or
|
|
146
|
+
# 'legacy' (SALN, AMLv), from the pushed state's audio.mixer. A
|
|
147
|
+
# subscribe that comes before the state names the mixer is pending
|
|
148
|
+
# and armed by the push that does (_arm_deferred_audio_meters).
|
|
149
|
+
self._meter_mixer = None
|
|
150
|
+
self._meter_pending = False
|
|
144
151
|
self._meter_loop = None
|
|
145
152
|
self._meter_batch_strips: dict = {} # strip_id → latest payload this tick
|
|
146
153
|
self._meter_batch_master: dict | None = None
|
|
@@ -339,8 +346,10 @@ class ATEMConsumer(ATEMConnectionLoggingMixin, ATEMStateMixin, AsyncWebsocketCon
|
|
|
339
346
|
# the async consumer model and in case handlers grow to read state.
|
|
340
347
|
# KI#13: dispatch is pure atemwire (no ORM) — off the shared
|
|
341
348
|
# thread-sensitive lane so one slow call elsewhere can't queue
|
|
342
|
-
# every operator's commands behind it.
|
|
343
|
-
|
|
349
|
+
# every operator's commands behind it. The audio verbs go to the
|
|
350
|
+
# mixer the last pushed state named (Fairlight or classic).
|
|
351
|
+
ok, err = await sync_to_async(dispatch_command, thread_sensitive=False)(
|
|
352
|
+
self.connection, command, data, self._audio_mixer())
|
|
344
353
|
|
|
345
354
|
if not ok:
|
|
346
355
|
logger.warning(f"Command {command} failed: {err}")
|
|
@@ -477,6 +486,9 @@ class ATEMConsumer(ATEMConnectionLoggingMixin, ATEMStateMixin, AsyncWebsocketCon
|
|
|
477
486
|
# _audio_subscribed True so the new ATEM's subscribe no-oped.
|
|
478
487
|
self._unsubscribe_audio_meters()
|
|
479
488
|
await sync_to_async(self._release_pool_ref, thread_sensitive=False)()
|
|
489
|
+
# The old switcher's state, its audio mixer with it, must not
|
|
490
|
+
# route the new one's commands before its first push.
|
|
491
|
+
self.last_known_state = {}
|
|
480
492
|
|
|
481
493
|
await sync_to_async(self._acquire_pool_ref, thread_sensitive=False)(ip_address)
|
|
482
494
|
|
|
@@ -755,6 +767,7 @@ class ATEMConsumer(ATEMConnectionLoggingMixin, ATEMStateMixin, AsyncWebsocketCon
|
|
|
755
767
|
t_send = time.monotonic() - t1
|
|
756
768
|
sent = True
|
|
757
769
|
self.last_known_state = current_state.copy()
|
|
770
|
+
self._arm_deferred_audio_meters()
|
|
758
771
|
self.last_change_time = asyncio.get_event_loop().time()
|
|
759
772
|
consecutive_no_changes = 0
|
|
760
773
|
else:
|
|
@@ -941,6 +954,13 @@ class ATEMConsumer(ATEMConnectionLoggingMixin, ATEMStateMixin, AsyncWebsocketCon
|
|
|
941
954
|
finally:
|
|
942
955
|
await sync_to_async(media_pool_service.release, thread_sensitive=False)(ip)
|
|
943
956
|
|
|
957
|
+
def _audio_mixer(self):
|
|
958
|
+
"""``audio.mixer`` of the state last pushed for this connection:
|
|
959
|
+
'fairlight', 'legacy' (the classic mixer) or 'unknown'. None before
|
|
960
|
+
the first push, and with a bmdwire older than 1.6, which has no
|
|
961
|
+
such key; both are treated as Fairlight, as before."""
|
|
962
|
+
return (self.last_known_state.get('audio') or {}).get('mixer')
|
|
963
|
+
|
|
944
964
|
# =========================================================================
|
|
945
965
|
# Audio meter subscription
|
|
946
966
|
# =========================================================================
|
|
@@ -953,40 +973,80 @@ class ATEMConsumer(ATEMConnectionLoggingMixin, ATEMStateMixin, AsyncWebsocketCon
|
|
|
953
973
|
|
|
954
974
|
METER_BATCH_INTERVAL_S = 0.04 # ~25 Hz total batch flush
|
|
955
975
|
|
|
976
|
+
def _meter_mixer_now(self):
|
|
977
|
+
"""The mixer to arm meters for, from the last pushed state:
|
|
978
|
+
'fairlight' or 'legacy', or None while it hasn't said (no state yet,
|
|
979
|
+
'unknown', or a state without audio). A state with audio but no
|
|
980
|
+
``mixer`` key comes from a bmdwire before 1.6: Fairlight, as
|
|
981
|
+
always."""
|
|
982
|
+
audio = (self.last_known_state or {}).get('audio')
|
|
983
|
+
if audio is None:
|
|
984
|
+
return None
|
|
985
|
+
mixer = audio.get('mixer', 'fairlight')
|
|
986
|
+
return mixer if mixer in ('fairlight', 'legacy') else None
|
|
987
|
+
|
|
988
|
+
def _arm_deferred_audio_meters(self):
|
|
989
|
+
"""After each state push: a subscribe that came before the state
|
|
990
|
+
named the mixer is armed by the first push that does."""
|
|
991
|
+
if self._meter_pending and self._meter_mixer_now() is not None:
|
|
992
|
+
self._meter_pending = False
|
|
993
|
+
self._subscribe_audio_meters()
|
|
994
|
+
|
|
995
|
+
def _enable_meter_stream(self, enable):
|
|
996
|
+
"""Start or stop the armed mixer's meters: SFLN for Fairlight, SALN
|
|
997
|
+
for the classic mixer."""
|
|
998
|
+
if self._meter_mixer == 'legacy':
|
|
999
|
+
enable_audio_levels(self.connection, enable=enable)
|
|
1000
|
+
else:
|
|
1001
|
+
enable_fairlight_levels(self.connection, enable=enable)
|
|
1002
|
+
|
|
956
1003
|
def _subscribe_audio_meters(self):
|
|
957
1004
|
"""Idempotent. Register per-consumer meter listeners and bump the
|
|
958
1005
|
per-IP subscriber refcount; send SFLN(enable=True) to the ATEM on
|
|
959
|
-
the 0→1 transition so it starts streaming FMLv/FDLv packets.
|
|
1006
|
+
the 0→1 transition so it starts streaming FMLv/FDLv packets. On a
|
|
1007
|
+
classic mixer (the pushed state's audio.mixer is 'legacy') it is
|
|
1008
|
+
SALN and AMLv instead; until a push names the mixer the subscribe
|
|
1009
|
+
waits (``_meter_pending``).
|
|
960
1010
|
|
|
961
1011
|
Captures the current asyncio loop so the (sync) atemwire event
|
|
962
1012
|
callbacks can hand events back to the consumer's loop via
|
|
963
1013
|
``call_soon_threadsafe``.
|
|
964
1014
|
"""
|
|
965
|
-
if self._audio_subscribed:
|
|
1015
|
+
if self._audio_subscribed or self._meter_pending:
|
|
966
1016
|
return
|
|
967
1017
|
protocol = getattr(self.connection, 'protocol', None)
|
|
968
1018
|
if protocol is None:
|
|
969
1019
|
return
|
|
1020
|
+
mixer = self._meter_mixer_now()
|
|
1021
|
+
if mixer is None:
|
|
1022
|
+
self._meter_pending = True
|
|
1023
|
+
return
|
|
1024
|
+
self._meter_mixer = mixer
|
|
970
1025
|
self._meter_loop = asyncio.get_event_loop()
|
|
971
1026
|
self._meter_batch_strips = {}
|
|
972
1027
|
self._meter_batch_master = None
|
|
973
1028
|
self._meter_batch_pending = False
|
|
974
1029
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1030
|
+
if mixer == 'legacy':
|
|
1031
|
+
# One AMLv carries every input and the master (bare event).
|
|
1032
|
+
lid = protocol.on('change:audio-meter-levels', self._on_legacy_meter)
|
|
1033
|
+
self._meter_handler_ids.append(('change:audio-meter-levels', lid))
|
|
1034
|
+
else:
|
|
1035
|
+
# Per-strip meter levels. NOTE: the wildcard ':*' is only emitted
|
|
1036
|
+
# by AtemProtocol when the field is registered in
|
|
1037
|
+
# FIELDNAME_UNIQUE — and 'fairlight-meter-levels' is NOT, despite
|
|
1038
|
+
# being multi-strip. Each FMLv arrives as the bare event name
|
|
1039
|
+
# carrying a single strip's data; the FMLv field has .strip_id
|
|
1040
|
+
# so we discriminate inside the handler.
|
|
1041
|
+
sid = protocol.on('change:fairlight-meter-levels',
|
|
1042
|
+
self._on_strip_meter)
|
|
1043
|
+
self._meter_handler_ids.append(
|
|
1044
|
+
('change:fairlight-meter-levels', sid))
|
|
1045
|
+
|
|
1046
|
+
# Master bus meter levels.
|
|
1047
|
+
mid = protocol.on('change:fairlight-master-levels',
|
|
1048
|
+
self._on_master_meter)
|
|
1049
|
+
self._meter_handler_ids.append(('change:fairlight-master-levels', mid))
|
|
990
1050
|
|
|
991
1051
|
# A new switcher session (the transport re-handshaking after a
|
|
992
1052
|
# silence) has not had SFLN, so it streams no meters until it
|
|
@@ -1004,9 +1064,9 @@ class ATEMConsumer(ATEMConnectionLoggingMixin, ATEMStateMixin, AsyncWebsocketCon
|
|
|
1004
1064
|
_meter_subscribers[ip] = prev + 1
|
|
1005
1065
|
if prev == 0:
|
|
1006
1066
|
try:
|
|
1007
|
-
|
|
1067
|
+
self._enable_meter_stream(True)
|
|
1008
1068
|
except Exception as e:
|
|
1009
|
-
logger.warning(f"Failed to enable
|
|
1069
|
+
logger.warning(f"Failed to enable the {mixer} meter stream: {e}")
|
|
1010
1070
|
|
|
1011
1071
|
self._audio_subscribed = True
|
|
1012
1072
|
self._meter_protocol = protocol
|
|
@@ -1031,17 +1091,21 @@ class ATEMConsumer(ATEMConnectionLoggingMixin, ATEMStateMixin, AsyncWebsocketCon
|
|
|
1031
1091
|
logger.info(
|
|
1032
1092
|
f"Re-arming audio meters after in-place reconnect ({self.current_ip})")
|
|
1033
1093
|
self._meter_handler_ids = [] # old ids died with the old protocol
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1094
|
+
if self._meter_mixer == 'legacy':
|
|
1095
|
+
lid = protocol.on('change:audio-meter-levels', self._on_legacy_meter)
|
|
1096
|
+
self._meter_handler_ids.append(('change:audio-meter-levels', lid))
|
|
1097
|
+
else:
|
|
1098
|
+
sid = protocol.on('change:fairlight-meter-levels', self._on_strip_meter)
|
|
1099
|
+
self._meter_handler_ids.append(('change:fairlight-meter-levels', sid))
|
|
1100
|
+
mid = protocol.on('change:fairlight-master-levels', self._on_master_meter)
|
|
1101
|
+
self._meter_handler_ids.append(('change:fairlight-master-levels', mid))
|
|
1038
1102
|
cid = protocol.on('connected', self._on_session_connected)
|
|
1039
1103
|
self._meter_handler_ids.append(('connected', cid))
|
|
1040
1104
|
self._meter_protocol = protocol
|
|
1041
1105
|
try:
|
|
1042
|
-
|
|
1106
|
+
self._enable_meter_stream(True)
|
|
1043
1107
|
except Exception as e:
|
|
1044
|
-
logger.warning(f"
|
|
1108
|
+
logger.warning(f"Meter re-arm failed: {e}")
|
|
1045
1109
|
|
|
1046
1110
|
def _on_session_connected(self):
|
|
1047
1111
|
"""Handler for the protocol's 'connected' event, which fires when
|
|
@@ -1056,14 +1120,19 @@ class ATEMConsumer(ATEMConnectionLoggingMixin, ATEMStateMixin, AsyncWebsocketCon
|
|
|
1056
1120
|
return
|
|
1057
1121
|
logger.info(f"Re-arming audio meters for the new switcher session ({self.current_ip})")
|
|
1058
1122
|
try:
|
|
1059
|
-
|
|
1123
|
+
self._enable_meter_stream(True)
|
|
1060
1124
|
except Exception as e:
|
|
1061
|
-
logger.warning(f"
|
|
1125
|
+
logger.warning(f"Meter re-arm after reconnect failed: {e}")
|
|
1062
1126
|
|
|
1063
1127
|
def _unsubscribe_audio_meters(self):
|
|
1064
1128
|
"""Idempotent. Unregister per-consumer meter listeners and
|
|
1065
1129
|
decrement the per-IP refcount; send SFLN(enable=False) on 1→0
|
|
1066
|
-
so the ATEM stops streaming once nobody's watching
|
|
1130
|
+
so the ATEM stops streaming once nobody's watching (SALN on a
|
|
1131
|
+
classic mixer). A subscribe still waiting for the mixer is just
|
|
1132
|
+
dropped."""
|
|
1133
|
+
if self._meter_pending:
|
|
1134
|
+
self._meter_pending = False
|
|
1135
|
+
return
|
|
1067
1136
|
if not self._audio_subscribed:
|
|
1068
1137
|
return
|
|
1069
1138
|
protocol = getattr(self.connection, 'protocol', None) if self.connection else None
|
|
@@ -1087,12 +1156,13 @@ class ATEMConsumer(ATEMConnectionLoggingMixin, ATEMStateMixin, AsyncWebsocketCon
|
|
|
1087
1156
|
if prev == 1:
|
|
1088
1157
|
try:
|
|
1089
1158
|
if self.connection is not None:
|
|
1090
|
-
|
|
1159
|
+
self._enable_meter_stream(False)
|
|
1091
1160
|
except Exception as e:
|
|
1092
|
-
logger.warning(f"Failed to disable
|
|
1161
|
+
logger.warning(f"Failed to disable the {self._meter_mixer} meter stream: {e}")
|
|
1093
1162
|
_meter_subscribers.pop(ip, None)
|
|
1094
1163
|
|
|
1095
1164
|
self._audio_subscribed = False
|
|
1165
|
+
self._meter_mixer = None
|
|
1096
1166
|
|
|
1097
1167
|
def _on_strip_meter(self, contents):
|
|
1098
1168
|
"""Handler for FMLv events — runs on the atemwire worker thread.
|
|
@@ -1119,6 +1189,51 @@ class ATEMConsumer(ATEMConnectionLoggingMixin, ATEMStateMixin, AsyncWebsocketCon
|
|
|
1119
1189
|
except RuntimeError:
|
|
1120
1190
|
pass # loop closed
|
|
1121
1191
|
|
|
1192
|
+
def _on_legacy_meter(self, contents):
|
|
1193
|
+
"""Handler for AMLv, the classic mixer's levels — runs on the
|
|
1194
|
+
atemwire worker thread. One packet carries every input and the
|
|
1195
|
+
master, each (L, R, peak L, peak R) in dB; they go into the batch
|
|
1196
|
+
as the Fairlight strips and master do, input ``n`` as strip
|
|
1197
|
+
``"n.0"``. AMLv has one level per input where FMLv has three
|
|
1198
|
+
stages, so it fills ``input`` (which the painter needs) and
|
|
1199
|
+
``level`` (which it paints); the dynamics fields have no
|
|
1200
|
+
counterpart. Untested on a classic switcher."""
|
|
1201
|
+
loop = self._meter_loop
|
|
1202
|
+
if loop is None or loop.is_closed():
|
|
1203
|
+
return
|
|
1204
|
+
inputs = getattr(contents, 'input', None) or {}
|
|
1205
|
+
strips = []
|
|
1206
|
+
for src, levels in inputs.items():
|
|
1207
|
+
m = self._legacy_meter_levels(levels)
|
|
1208
|
+
strips.append((f'{src}.0', {'strip_id': f'{src}.0', 'input': m, 'level': m}))
|
|
1209
|
+
m = self._legacy_meter_levels(getattr(contents, 'master', None))
|
|
1210
|
+
master = {'input': m, 'level': m}
|
|
1211
|
+
try:
|
|
1212
|
+
loop.call_soon_threadsafe(self._enqueue_legacy_meters, strips, master)
|
|
1213
|
+
except RuntimeError:
|
|
1214
|
+
pass # loop closed
|
|
1215
|
+
|
|
1216
|
+
@staticmethod
|
|
1217
|
+
def _legacy_meter_levels(t):
|
|
1218
|
+
"""An AMLv (L, R, peak L, peak R) tuple in ``_meter_tuple``'s JSON
|
|
1219
|
+
shape. Anything unreadable is silence (-60, AMLv's own floor), not
|
|
1220
|
+
0 dB, which would paint full scale."""
|
|
1221
|
+
try:
|
|
1222
|
+
return {
|
|
1223
|
+
'l': round(float(t[0]), 2),
|
|
1224
|
+
'r': round(float(t[1]), 2),
|
|
1225
|
+
'peak_l': round(float(t[2]), 2),
|
|
1226
|
+
'peak_r': round(float(t[3]), 2),
|
|
1227
|
+
}
|
|
1228
|
+
except (IndexError, TypeError, ValueError):
|
|
1229
|
+
return {'l': -60.0, 'r': -60.0, 'peak_l': -60.0, 'peak_r': -60.0}
|
|
1230
|
+
|
|
1231
|
+
def _enqueue_legacy_meters(self, strips, master):
|
|
1232
|
+
for sid, payload in strips:
|
|
1233
|
+
self._meter_batch_strips[sid] = payload
|
|
1234
|
+
self._meter_batch_master = master
|
|
1235
|
+
self._arm_meter_batch_flush()
|
|
1236
|
+
|
|
1122
1237
|
def _on_master_meter(self, contents):
|
|
1123
1238
|
"""Handler for FDLv — runs on atemwire worker thread."""
|
|
1124
1239
|
loop = self._meter_loop
|
|
@@ -494,6 +494,19 @@
|
|
|
494
494
|
align-items: stretch;
|
|
495
495
|
}
|
|
496
496
|
|
|
497
|
+
/* The classic (pre-Fairlight) mixer has no input gain, EQ or dynamics: the
|
|
498
|
+
template leaves those three cells out (x-if), so their tracks go too. */
|
|
499
|
+
.audio-strip-grid.is-classic {
|
|
500
|
+
grid-template-rows:
|
|
501
|
+
28px /* header (name + on-bar) */
|
|
502
|
+
20px /* dB readout */
|
|
503
|
+
minmax(0, 1fr) /* channel (fader + meter) — flex-shrink */
|
|
504
|
+
20px /* fader value */
|
|
505
|
+
66px /* pan knob + numeric */
|
|
506
|
+
26px /* mix buttons */
|
|
507
|
+
24px; /* phones */
|
|
508
|
+
}
|
|
509
|
+
|
|
497
510
|
/* On-air indicator bar above the strip name */
|
|
498
511
|
.audio-on-bar {
|
|
499
512
|
height: 3px;
|
|
@@ -1434,6 +1434,7 @@
|
|
|
1434
1434
|
alpineInstance.connected = data.connected;
|
|
1435
1435
|
alpineInstance.statusText = data.message;
|
|
1436
1436
|
if (data.connected) {
|
|
1437
|
+
alpineInstance.armAudioMixerWait();
|
|
1437
1438
|
this.isReconnecting = false;
|
|
1438
1439
|
this.connectingIP = null;
|
|
1439
1440
|
this.isUserInitiatedConnection = false;
|
|
@@ -2815,12 +2816,15 @@
|
|
|
2815
2816
|
// ``sourcesFor(ctx)`` can filter per dropdown context.
|
|
2816
2817
|
// Empty until InPr arrives.
|
|
2817
2818
|
sources: [],
|
|
2818
|
-
//
|
|
2819
|
+
// Audio. ``present: false`` is the loading-gate the
|
|
2819
2820
|
// template watches — the Fairlight handshake arrives ~hundreds
|
|
2820
2821
|
// of ms after the rest of the state dump. Default empty
|
|
2821
2822
|
// strips/master so the template can read fields safely before
|
|
2822
|
-
// the first state push lands.
|
|
2823
|
+
// the first state push lands. ``mixer`` is 'fairlight', 'legacy'
|
|
2824
|
+
// (the classic mixer, which has no input gain, EQ or dynamics:
|
|
2825
|
+
// see ``classicAudio``) or 'unknown' until FAMP or AMMO arrives.
|
|
2823
2826
|
audio: {
|
|
2827
|
+
mixer: "unknown",
|
|
2824
2828
|
present: false,
|
|
2825
2829
|
master: {
|
|
2826
2830
|
present: false,
|
|
@@ -4110,6 +4114,27 @@
|
|
|
4110
4114
|
send(command, params = {}) {
|
|
4111
4115
|
ATEMControl.cmd(command, params);
|
|
4112
4116
|
},
|
|
4117
|
+
// The switcher has the classic (pre-Fairlight) audio mixer: the
|
|
4118
|
+
// panel hides the input gain, EQ and dynamics it doesn't have.
|
|
4119
|
+
get classicAudio() {
|
|
4120
|
+
return this.state.audio?.mixer === "legacy";
|
|
4121
|
+
},
|
|
4122
|
+
// 10 s after each connect, a state that still names no audio mixer
|
|
4123
|
+
// (neither FAMP nor AMMO arrived) is a switcher without one: the
|
|
4124
|
+
// panel says so instead of waiting. A state without the key at all
|
|
4125
|
+
// (bmdwire before 1.6) keeps waiting, as it always did.
|
|
4126
|
+
audioMixerWaitOver: false,
|
|
4127
|
+
_audioMixerWaitTimer: null,
|
|
4128
|
+
armAudioMixerWait() {
|
|
4129
|
+
clearTimeout(this._audioMixerWaitTimer);
|
|
4130
|
+
this.audioMixerWaitOver = false;
|
|
4131
|
+
this._audioMixerWaitTimer = setTimeout(() => {
|
|
4132
|
+
this.audioMixerWaitOver = true;
|
|
4133
|
+
}, 1e4);
|
|
4134
|
+
},
|
|
4135
|
+
get audioNoMixer() {
|
|
4136
|
+
return this.audioMixerWaitOver && this.state.audio?.mixer === "unknown";
|
|
4137
|
+
},
|
|
4113
4138
|
// Audio meter subscription — tab-gated. The Fairlight meter stream
|
|
4114
4139
|
// is ~375 msg/s sustained on a 14-strip mixer, which saturates the
|
|
4115
4140
|
// WebSocket write buffer and causes the atem_state lag tracked as
|
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
AudioMeters in atem_control.js.
|
|
12
12
|
|
|
13
13
|
EQ band detail / dynamics tuning aren't surfaced in this MVP
|
|
14
|
-
(per atemwire coverage: comp/lim/exp can't be written today).
|
|
14
|
+
(per atemwire coverage: comp/lim/exp can't be written today).
|
|
15
|
+
|
|
16
|
+
A classic (pre-Fairlight) mixer — state.audio.mixer 'legacy',
|
|
17
|
+
$store.atem.classicAudio — shows the same panel less the input
|
|
18
|
+
gain, EQ and dynamics rows it doesn't have; its faders top out at
|
|
19
|
+
+6 dB. Built from protocol docs, untested on a classic switcher. -->
|
|
15
20
|
<div class="fixed inset-0 z-[200] pointer-events-none" x-show="audioPanelOpen" x-cloak
|
|
16
21
|
@keydown.escape.window="if (!audioModal) audioPanelOpen = false">
|
|
17
22
|
<!-- Backdrop. x-show on the backdrop is load-bearing — without it,
|
|
@@ -45,12 +50,12 @@
|
|
|
45
50
|
<div class="flex items-center justify-between p-4 border-b border-base-300 flex-shrink-0">
|
|
46
51
|
<h2 class="text-lg font-semibold flex items-center gap-2">
|
|
47
52
|
<i class="bi bi-sliders"></i>
|
|
48
|
-
Fairlight Audio Mixer
|
|
53
|
+
<span x-text="['legacy', 'unknown'].includes($store.atem.state?.audio?.mixer) ? 'Audio Mixer' : 'Fairlight Audio Mixer'">Fairlight Audio Mixer</span>
|
|
49
54
|
<span class="text-sm text-base-content/50 font-normal ml-2"
|
|
50
55
|
x-show="$store.atem.state && $store.atem.state.audio && $store.atem.state.audio.present"
|
|
51
56
|
x-text="($store.atem.state?.audio?.strips?.length || 0) + ' strips'"></span>
|
|
52
57
|
<span class="text-sm text-base-content/50 font-normal ml-2"
|
|
53
|
-
x-show="!$store.atem.state || !$store.atem.state.audio || !$store.atem.state.audio.present">
|
|
58
|
+
x-show="(!$store.atem.state || !$store.atem.state.audio || !$store.atem.state.audio.present) && !$store.atem.audioNoMixer">
|
|
54
59
|
Loading…
|
|
55
60
|
</span>
|
|
56
61
|
</h2>
|
|
@@ -59,13 +64,20 @@
|
|
|
59
64
|
</button>
|
|
60
65
|
</div>
|
|
61
66
|
|
|
62
|
-
<!-- Loading state
|
|
67
|
+
<!-- Loading state. A switcher whose state still names no audio
|
|
68
|
+
mixer 10 s after connecting has none (store.audioNoMixer). -->
|
|
63
69
|
<div x-show="!$store.atem.state || !$store.atem.state.audio || !$store.atem.state.audio.present"
|
|
64
70
|
class="flex-1 flex items-center justify-center">
|
|
65
|
-
<div class="flex flex-col items-center gap-3">
|
|
71
|
+
<div class="flex flex-col items-center gap-3" x-show="!$store.atem.audioNoMixer">
|
|
66
72
|
<span class="loading loading-spinner loading-lg"></span>
|
|
67
73
|
<span class="text-sm text-base-content/60">
|
|
68
|
-
Waiting for
|
|
74
|
+
Waiting for audio state…
|
|
75
|
+
</span>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="flex flex-col items-center gap-3" x-show="$store.atem.audioNoMixer" x-cloak>
|
|
78
|
+
<i class="bi bi-volume-mute text-3xl text-base-content/40"></i>
|
|
79
|
+
<span class="text-sm text-base-content/60">
|
|
80
|
+
This switcher reports no audio mixer
|
|
69
81
|
</span>
|
|
70
82
|
</div>
|
|
71
83
|
</div>
|
|
@@ -86,14 +98,24 @@
|
|
|
86
98
|
<div x-show="$store.atem.state && $store.atem.state.audio && $store.atem.state.audio.present"
|
|
87
99
|
class="flex-1 min-h-0 overflow-x-auto overflow-y-auto flex pb-3 audio-mixer-root">
|
|
88
100
|
|
|
89
|
-
<!-- Labels rail (sticky left)
|
|
101
|
+
<!-- Labels rail (sticky left). On the classic mixer
|
|
102
|
+
(is-classic) the Input / EQ / Dyn rows are gone from
|
|
103
|
+
every grid here: x-if, not x-show, so nothing in them
|
|
104
|
+
reads the input gain, EQ or dynamics a classic strip
|
|
105
|
+
doesn't carry, and the grid drops their tracks
|
|
106
|
+
(.audio-strip-grid.is-classic). -->
|
|
90
107
|
<div class="audio-strip-grid sticky left-0 z-10 bg-base-200
|
|
91
108
|
text-[11px] text-base-content/60 border-r border-base-300
|
|
92
|
-
w-[88px] min-w-[88px] flex-shrink-0 px-3 uppercase tracking-wider"
|
|
109
|
+
w-[88px] min-w-[88px] flex-shrink-0 px-3 uppercase tracking-wider"
|
|
110
|
+
:class="{'is-classic': $store.atem.classicAudio}">
|
|
93
111
|
<div class="row-header"></div>
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
112
|
+
<template x-if="!$store.atem.classicAudio">
|
|
113
|
+
<div class="contents">
|
|
114
|
+
<div class="row-input flex items-center">Input</div>
|
|
115
|
+
<div class="row-eq flex items-center">EQ</div>
|
|
116
|
+
<div class="row-dyn flex items-center">Dyn</div>
|
|
117
|
+
</div>
|
|
118
|
+
</template>
|
|
97
119
|
<div class="row-db flex items-center">dB</div>
|
|
98
120
|
<div class="row-channel"></div>
|
|
99
121
|
<div class="row-fader-value"></div>
|
|
@@ -115,7 +137,8 @@
|
|
|
115
137
|
<div class="audio-strip-grid audio-strip-card flex-1 min-w-[100px]"
|
|
116
138
|
:class="{
|
|
117
139
|
'is-on': strip.mix_type === 'On',
|
|
118
|
-
'is-afv': strip.mix_type === 'AudioFollowVideo'
|
|
140
|
+
'is-afv': strip.mix_type === 'AudioFollowVideo',
|
|
141
|
+
'is-classic': $store.atem.classicAudio
|
|
119
142
|
}">
|
|
120
143
|
<!-- Header: name + on indicator bar -->
|
|
121
144
|
<div class="row-header flex flex-col items-center justify-end pb-0.5">
|
|
@@ -129,6 +152,9 @@
|
|
|
129
152
|
}"></div>
|
|
130
153
|
</div>
|
|
131
154
|
|
|
155
|
+
<!-- Input gain, EQ and dynamics: Fairlight only. -->
|
|
156
|
+
<template x-if="!$store.atem.classicAudio">
|
|
157
|
+
<div class="contents">
|
|
132
158
|
<!-- Input gain: drag knob + numeric input.
|
|
133
159
|
Drag vertically; Shift = ¼ sensitivity;
|
|
134
160
|
dblclick = reset to 0 dB; wheel = ±2 dB
|
|
@@ -192,6 +218,8 @@
|
|
|
192
218
|
stroke-width="1.2" fill="none"></path>
|
|
193
219
|
</svg>
|
|
194
220
|
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</template>
|
|
195
223
|
|
|
196
224
|
<!-- dB readout — live meter peak (text written
|
|
197
225
|
by AudioMeters._paintLevelText, blank when
|
|
@@ -213,7 +241,8 @@
|
|
|
213
241
|
<div class="audio-fader-track"
|
|
214
242
|
x-init="AudioFader.attach($el, {
|
|
215
243
|
stripId: strip.strip_id,
|
|
216
|
-
|
|
244
|
+
/* The classic mixer tops out at +6 dB. */
|
|
245
|
+
min: -100, max: $store.atem.classicAudio ? 6 : 10, taper: 2,
|
|
217
246
|
getValue: () => strip.volume_db ?? -100,
|
|
218
247
|
send: (v) => ATEMControl.cmdAudio(
|
|
219
248
|
'set_audio_strip_volume', strip,
|
|
@@ -306,6 +335,7 @@
|
|
|
306
335
|
more strips while master stays in view. -->
|
|
307
336
|
<div class="audio-strip-grid audio-master-card sticky right-0 z-10
|
|
308
337
|
w-[130px] min-w-[130px] flex-shrink-0"
|
|
338
|
+
:class="{'is-classic': $store.atem.classicAudio}"
|
|
309
339
|
x-data="{master() { return $store.atem.state?.audio?.master ?? null; }}">
|
|
310
340
|
<template x-if="master()">
|
|
311
341
|
<div class="contents">
|
|
@@ -315,6 +345,9 @@
|
|
|
315
345
|
<div class="audio-on-bar w-3/4 mt-0.5 is-on"></div>
|
|
316
346
|
</div>
|
|
317
347
|
|
|
348
|
+
<!-- Input row, EQ and dynamics: Fairlight only. -->
|
|
349
|
+
<template x-if="!$store.atem.classicAudio">
|
|
350
|
+
<div class="contents">
|
|
318
351
|
<!-- Input row: empty on master -->
|
|
319
352
|
<div class="row-input"></div>
|
|
320
353
|
|
|
@@ -351,6 +384,8 @@
|
|
|
351
384
|
stroke-width="1.2" fill="none"></path>
|
|
352
385
|
</svg>
|
|
353
386
|
</div>
|
|
387
|
+
</div>
|
|
388
|
+
</template>
|
|
354
389
|
|
|
355
390
|
<!-- dB readout — live master peak; blank when silent -->
|
|
356
391
|
<div class="row-db flex items-center justify-center text-[11px] font-mono">
|
|
@@ -362,7 +397,7 @@
|
|
|
362
397
|
<div class="audio-fader-track"
|
|
363
398
|
x-init="AudioFader.attach($el, {
|
|
364
399
|
stripId: '_master',
|
|
365
|
-
min: -100, max: 10, taper: 2,
|
|
400
|
+
min: -100, max: $store.atem.classicAudio ? 6 : 10, taper: 2,
|
|
366
401
|
getValue: () => master().volume_db ?? -100,
|
|
367
402
|
send: (v) => ATEMControl.cmd(
|
|
368
403
|
'set_audio_master_volume',
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webatem"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.2.0"
|
|
8
8
|
description = "Browser-based control for Blackmagic ATEM switchers: run it, open the browser, your switchers are there"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/IBMPlexMono-400-latin-ext.woff2
RENAMED
|
File without changes
|
|
File without changes
|
{webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/IBMPlexMono-500-latin-ext.woff2
RENAMED
|
File without changes
|
|
File without changes
|
{webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/IBMPlexMono-600-latin-ext.woff2
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webatem-2.1.6 → webatem-2.2.0}/atem_control/static/vendor/bootstrap-icons/bootstrap-icons.min.css
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|