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.
Files changed (135) hide show
  1. {webatem-2.1.6 → webatem-2.2.0}/PKG-INFO +1 -1
  2. {webatem-2.1.6 → webatem-2.2.0}/atem_control/control/commands.py +86 -7
  3. {webatem-2.1.6 → webatem-2.2.0}/atem_control/control/consumer.py +147 -32
  4. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/css/atem_control.css +13 -0
  5. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/atem_control.js +27 -2
  6. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_fairlight.html +49 -14
  7. {webatem-2.1.6 → webatem-2.2.0}/pyproject.toml +1 -1
  8. {webatem-2.1.6 → webatem-2.2.0}/webatem.egg-info/PKG-INFO +1 -1
  9. {webatem-2.1.6 → webatem-2.2.0}/LICENSE +0 -0
  10. {webatem-2.1.6 → webatem-2.2.0}/README.md +0 -0
  11. {webatem-2.1.6 → webatem-2.2.0}/atem_control/__init__.py +0 -0
  12. {webatem-2.1.6 → webatem-2.2.0}/atem_control/activity.py +0 -0
  13. {webatem-2.1.6 → webatem-2.2.0}/atem_control/apps.py +0 -0
  14. {webatem-2.1.6 → webatem-2.2.0}/atem_control/control/__init__.py +0 -0
  15. {webatem-2.1.6 → webatem-2.2.0}/atem_control/control/activity.py +0 -0
  16. {webatem-2.1.6 → webatem-2.2.0}/atem_control/control/device_api.py +0 -0
  17. {webatem-2.1.6 → webatem-2.2.0}/atem_control/control/logging.py +0 -0
  18. {webatem-2.1.6 → webatem-2.2.0}/atem_control/control/views.py +0 -0
  19. {webatem-2.1.6 → webatem-2.2.0}/atem_control/discovery.py +0 -0
  20. {webatem-2.1.6 → webatem-2.2.0}/atem_control/hooks.py +0 -0
  21. {webatem-2.1.6 → webatem-2.2.0}/atem_control/hyperdeck/__init__.py +0 -0
  22. {webatem-2.1.6 → webatem-2.2.0}/atem_control/hyperdeck/connection.py +0 -0
  23. {webatem-2.1.6 → webatem-2.2.0}/atem_control/hyperdeck/views.py +0 -0
  24. {webatem-2.1.6 → webatem-2.2.0}/atem_control/ip_upload_lock.py +0 -0
  25. {webatem-2.1.6 → webatem-2.2.0}/atem_control/media_pool/__init__.py +0 -0
  26. {webatem-2.1.6 → webatem-2.2.0}/atem_control/media_pool/broadcast.py +0 -0
  27. {webatem-2.1.6 → webatem-2.2.0}/atem_control/media_pool/still_cache.py +0 -0
  28. {webatem-2.1.6 → webatem-2.2.0}/atem_control/media_pool/views.py +0 -0
  29. {webatem-2.1.6 → webatem-2.2.0}/atem_control/media_pool/watcher.py +0 -0
  30. {webatem-2.1.6 → webatem-2.2.0}/atem_control/migrations/0001_initial.py +0 -0
  31. {webatem-2.1.6 → webatem-2.2.0}/atem_control/migrations/0002_legacy_log.py +0 -0
  32. {webatem-2.1.6 → webatem-2.2.0}/atem_control/migrations/__init__.py +0 -0
  33. {webatem-2.1.6 → webatem-2.2.0}/atem_control/models.py +0 -0
  34. {webatem-2.1.6 → webatem-2.2.0}/atem_control/netutil.py +0 -0
  35. {webatem-2.1.6 → webatem-2.2.0}/atem_control/profile/__init__.py +0 -0
  36. {webatem-2.1.6 → webatem-2.2.0}/atem_control/profile/dialog.py +0 -0
  37. {webatem-2.1.6 → webatem-2.2.0}/atem_control/profile/export.py +0 -0
  38. {webatem-2.1.6 → webatem-2.2.0}/atem_control/profile/views.py +0 -0
  39. {webatem-2.1.6 → webatem-2.2.0}/atem_control/routing.py +0 -0
  40. {webatem-2.1.6 → webatem-2.2.0}/atem_control/sightings.py +0 -0
  41. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/apple-touch-icon.png +0 -0
  42. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/brand.css +0 -0
  43. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/favicon-32.png +0 -0
  44. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/favicon.ico +0 -0
  45. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/Archivo-400-latin-ext.woff2 +0 -0
  46. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/Archivo-400-latin.woff2 +0 -0
  47. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/Archivo-500-latin-ext.woff2 +0 -0
  48. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/Archivo-500-latin.woff2 +0 -0
  49. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/Archivo-600-latin-ext.woff2 +0 -0
  50. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/Archivo-600-latin.woff2 +0 -0
  51. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/Archivo-800-latin-ext.woff2 +0 -0
  52. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/Archivo-800-latin.woff2 +0 -0
  53. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/IBMPlexMono-400-latin-ext.woff2 +0 -0
  54. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/IBMPlexMono-400-latin.woff2 +0 -0
  55. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/IBMPlexMono-500-latin-ext.woff2 +0 -0
  56. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/IBMPlexMono-500-latin.woff2 +0 -0
  57. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/IBMPlexMono-600-latin-ext.woff2 +0 -0
  58. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/IBMPlexMono-600-latin.woff2 +0 -0
  59. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/OFL-Archivo.txt +0 -0
  60. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/fonts/OFL-IBMPlexMono.txt +0 -0
  61. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/icon-192.png +0 -0
  62. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/icon-512.png +0 -0
  63. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/icon-maskable-512.png +0 -0
  64. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/manifest.webmanifest +0 -0
  65. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/mark.svg +0 -0
  66. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/tray/linux-24.png +0 -0
  67. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/tray/mac-22.png +0 -0
  68. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/tray/mac-44.png +0 -0
  69. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/tray/win-16.png +0 -0
  70. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/brand/tray/win-32.png +0 -0
  71. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/css/atem_connect.css +0 -0
  72. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/icon.png +0 -0
  73. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/atem_connect.js +0 -0
  74. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/atem_control_page.js +0 -0
  75. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/atem_discovery.js +0 -0
  76. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/atem_hyperdeck.js +0 -0
  77. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/atem_profile.js +0 -0
  78. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/atem_realtime_slider.js +0 -0
  79. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/custom-scrollbar.js +0 -0
  80. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/recent_label.js +0 -0
  81. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/js/wa-select.js +0 -0
  82. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/vendor/README.md +0 -0
  83. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/vendor/alpine.min.js +0 -0
  84. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/vendor/alpinejs-collapse-3.14.3.min.js +0 -0
  85. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/vendor/bootstrap-icons/bootstrap-icons.min.css +0 -0
  86. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  87. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  88. {webatem-2.1.6 → webatem-2.2.0}/atem_control/static/vendor/webatem.css +0 -0
  89. {webatem-2.1.6 → webatem-2.2.0}/atem_control/storage.py +0 -0
  90. {webatem-2.1.6 → webatem-2.2.0}/atem_control/tally.py +0 -0
  91. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/connect.html +0 -0
  92. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_atem_info.html +0 -0
  93. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_fade_to_black.html +0 -0
  94. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_header.html +0 -0
  95. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_hyperdeck_panel.html +0 -0
  96. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_macros.html +0 -0
  97. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_media_pool.html +0 -0
  98. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_profile_modal.html +0 -0
  99. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_settings_dsk.html +0 -0
  100. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_settings_transition.html +0 -0
  101. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_settings_usk.html +0 -0
  102. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_slider.html +0 -0
  103. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_transition_controls.html +0 -0
  104. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_transition_style.html +0 -0
  105. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_usk_fly.html +0 -0
  106. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_usk_keyframe.html +0 -0
  107. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_usk_mask.html +0 -0
  108. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control/_usk_slider.html +0 -0
  109. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templates/control.html +0 -0
  110. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templatetags/__init__.py +0 -0
  111. {webatem-2.1.6 → webatem-2.2.0}/atem_control/templatetags/static_v.py +0 -0
  112. {webatem-2.1.6 → webatem-2.2.0}/atem_control/uploader.py +0 -0
  113. {webatem-2.1.6 → webatem-2.2.0}/atem_control/urls.py +0 -0
  114. {webatem-2.1.6 → webatem-2.2.0}/setup.cfg +0 -0
  115. {webatem-2.1.6 → webatem-2.2.0}/webatem/__init__.py +0 -0
  116. {webatem-2.1.6 → webatem-2.2.0}/webatem/__main__.py +0 -0
  117. {webatem-2.1.6 → webatem-2.2.0}/webatem/applog.py +0 -0
  118. {webatem-2.1.6 → webatem-2.2.0}/webatem/asgi.py +0 -0
  119. {webatem-2.1.6 → webatem-2.2.0}/webatem/context.py +0 -0
  120. {webatem-2.1.6 → webatem-2.2.0}/webatem/launcher.py +0 -0
  121. {webatem-2.1.6 → webatem-2.2.0}/webatem/server.py +0 -0
  122. {webatem-2.1.6 → webatem-2.2.0}/webatem/settings.py +0 -0
  123. {webatem-2.1.6 → webatem-2.2.0}/webatem/templates/base.html +0 -0
  124. {webatem-2.1.6 → webatem-2.2.0}/webatem/templates/brand/_mark.svg +0 -0
  125. {webatem-2.1.6 → webatem-2.2.0}/webatem/templates/launcher.html +0 -0
  126. {webatem-2.1.6 → webatem-2.2.0}/webatem/uninstall.py +0 -0
  127. {webatem-2.1.6 → webatem-2.2.0}/webatem/updates.py +0 -0
  128. {webatem-2.1.6 → webatem-2.2.0}/webatem/urls.py +0 -0
  129. {webatem-2.1.6 → webatem-2.2.0}/webatem/views.py +0 -0
  130. {webatem-2.1.6 → webatem-2.2.0}/webatem/websocket.py +0 -0
  131. {webatem-2.1.6 → webatem-2.2.0}/webatem.egg-info/SOURCES.txt +0 -0
  132. {webatem-2.1.6 → webatem-2.2.0}/webatem.egg-info/dependency_links.txt +0 -0
  133. {webatem-2.1.6 → webatem-2.2.0}/webatem.egg-info/entry_points.txt +0 -0
  134. {webatem-2.1.6 → webatem-2.2.0}/webatem.egg-info/requires.txt +0 -0
  135. {webatem-2.1.6 → webatem-2.2.0}/webatem.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webatem
3
- Version: 2.1.6
3
+ Version: 2.2.0
4
4
  Summary: Browser-based control for Blackmagic ATEM switchers: run it, open the browser, your switchers are there
5
5
  Author: Lucas Romanenko
6
6
  License: MIT
@@ -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) -> (ok: bool, error: Optional[str])
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, hyperdeck,
29
- input_video, macros, media, switching, transition, upstream_keyer,
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
- def dispatch(connection, command: str, data: dict) -> HandlerResult:
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
- handler = ATEM_COMMAND_MAP.get(command)
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, f"unknown command: {command}"
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
- ok, err = await sync_to_async(dispatch_command, thread_sensitive=False)(self.connection, command, data)
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
- # Per-strip meter levels. NOTE: the wildcard ':*' is only emitted
976
- # by AtemProtocol when the field is registered in
977
- # FIELDNAME_UNIQUE — and 'fairlight-meter-levels' is NOT, despite
978
- # being multi-strip. Each FMLv arrives as the bare event name
979
- # carrying a single strip's data; the FMLv field has .strip_id
980
- # so we discriminate inside the handler.
981
- sid = protocol.on('change:fairlight-meter-levels',
982
- self._on_strip_meter)
983
- self._meter_handler_ids.append(
984
- ('change:fairlight-meter-levels', sid))
985
-
986
- # Master bus meter levels.
987
- mid = protocol.on('change:fairlight-master-levels',
988
- self._on_master_meter)
989
- self._meter_handler_ids.append(('change:fairlight-master-levels', mid))
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
- enable_fairlight_levels(self.connection, enable=True)
1067
+ self._enable_meter_stream(True)
1008
1068
  except Exception as e:
1009
- logger.warning(f"Failed to enable Fairlight meter stream: {e}")
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
- sid = protocol.on('change:fairlight-meter-levels', self._on_strip_meter)
1035
- self._meter_handler_ids.append(('change:fairlight-meter-levels', sid))
1036
- mid = protocol.on('change:fairlight-master-levels', self._on_master_meter)
1037
- self._meter_handler_ids.append(('change:fairlight-master-levels', mid))
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
- enable_fairlight_levels(self.connection, enable=True)
1106
+ self._enable_meter_stream(True)
1043
1107
  except Exception as e:
1044
- logger.warning(f"SFLN re-arm failed: {e}")
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
- enable_fairlight_levels(self.connection, enable=True)
1123
+ self._enable_meter_stream(True)
1060
1124
  except Exception as e:
1061
- logger.warning(f"SFLN re-arm after reconnect failed: {e}")
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
- enable_fairlight_levels(self.connection, enable=False)
1159
+ self._enable_meter_stream(False)
1091
1160
  except Exception as e:
1092
- logger.warning(f"Failed to disable Fairlight meter stream: {e}")
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
- // Fairlight audio. ``present: false`` is the loading-gate the
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 Fairlight audio state…
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
- <div class="row-input flex items-center">Input</div>
95
- <div class="row-eq flex items-center">EQ</div>
96
- <div class="row-dyn flex items-center">Dyn</div>
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
- min: -100, max: 10, taper: 2,
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.1.6"
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" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webatem
3
- Version: 2.1.6
3
+ Version: 2.2.0
4
4
  Summary: Browser-based control for Blackmagic ATEM switchers: run it, open the browser, your switchers are there
5
5
  Author: Lucas Romanenko
6
6
  License: 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