streamo 0.2.0__py3-none-any.whl
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.
- streamo/__init__.py +1 -0
- streamo/__main__.py +20 -0
- streamo/audio.py +160 -0
- streamo/auth.py +204 -0
- streamo/closing.py +229 -0
- streamo/composition.py +156 -0
- streamo/config.py +308 -0
- streamo/control.py +319 -0
- streamo/credentials.py +23 -0
- streamo/daemon.py +90 -0
- streamo/ffmpeg_progress.py +24 -0
- streamo/image_feed.py +200 -0
- streamo/images.py +276 -0
- streamo/kick_api.py +392 -0
- streamo/moderation.py +122 -0
- streamo/overlays.py +379 -0
- streamo/preflight.py +443 -0
- streamo/provider_config.py +390 -0
- streamo/providers.py +461 -0
- streamo/runtime.py +396 -0
- streamo/service.toml +6 -0
- streamo/streamer.py +512 -0
- streamo/twitch_api.py +227 -0
- streamo/youtube_api.py +380 -0
- streamo-0.2.0.dist-info/METADATA +46 -0
- streamo-0.2.0.dist-info/RECORD +29 -0
- streamo-0.2.0.dist-info/WHEEL +4 -0
- streamo-0.2.0.dist-info/entry_points.txt +2 -0
- streamo-0.2.0.dist-info/licenses/LICENSE +21 -0
streamo/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Live audio and video streaming."""
|
streamo/__main__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from . import auth, daemon
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main(argv: list[str] | None = None) -> int:
|
|
7
|
+
arguments = sys.argv[1:] if argv is None else argv
|
|
8
|
+
if arguments[:1] == ['preflight']:
|
|
9
|
+
from . import preflight
|
|
10
|
+
|
|
11
|
+
return preflight.main(arguments[1:])
|
|
12
|
+
if arguments[:1] == ['auth']:
|
|
13
|
+
return auth.main(arguments[1:])
|
|
14
|
+
if arguments[:1] == ['daemon']:
|
|
15
|
+
arguments = arguments[1:]
|
|
16
|
+
return daemon.main(arguments)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if __name__ == '__main__':
|
|
20
|
+
raise SystemExit(main())
|
streamo/audio.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import queue
|
|
3
|
+
import time
|
|
4
|
+
from typing import IO
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import sounddevice
|
|
8
|
+
from reccy.runtime.retry import RetryPolicy, RetrySchedule
|
|
9
|
+
|
|
10
|
+
from .closing import ClosingSequence
|
|
11
|
+
from .runtime import RuntimeState
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AudioCapture:
|
|
15
|
+
"""Keep capture independent of FFmpeg; retain at most one second of audio."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
device: str,
|
|
20
|
+
channel: int,
|
|
21
|
+
sample_rate: int,
|
|
22
|
+
output: IO[bytes] | None,
|
|
23
|
+
state: RuntimeState,
|
|
24
|
+
closing: ClosingSequence | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.device = device
|
|
27
|
+
self.channel = channel
|
|
28
|
+
self.sample_rate = sample_rate
|
|
29
|
+
self.output = output
|
|
30
|
+
self.state = state
|
|
31
|
+
self.closing = closing
|
|
32
|
+
self.blocks: queue.Queue[tuple[np.ndarray, str]] = queue.Queue(
|
|
33
|
+
maxsize=max(1, sample_rate // 1024)
|
|
34
|
+
)
|
|
35
|
+
self.pending = b''
|
|
36
|
+
self.dropped_frames = 0
|
|
37
|
+
self.overflow_frames = 0
|
|
38
|
+
self.last_capture = time.monotonic()
|
|
39
|
+
self.last_write = self.last_capture
|
|
40
|
+
self.open_retry = RetrySchedule(RetryPolicy(delay=5), clock=time.monotonic)
|
|
41
|
+
self.capture_retry = RetrySchedule(RetryPolicy(delay=1), clock=time.monotonic)
|
|
42
|
+
self.capture: sounddevice.InputStream | None = None
|
|
43
|
+
self.error: str | None = None
|
|
44
|
+
if output is not None:
|
|
45
|
+
os.set_blocking(output.fileno(), False)
|
|
46
|
+
|
|
47
|
+
def attach_output(self, output: IO[bytes] | None) -> None:
|
|
48
|
+
"""Start a new encoder on fresh, complete audio frames."""
|
|
49
|
+
self.dropped_frames += (len(self.pending) + 7) // 8
|
|
50
|
+
self.pending = b''
|
|
51
|
+
while True:
|
|
52
|
+
try:
|
|
53
|
+
block, _ = self.blocks.get_nowait()
|
|
54
|
+
except queue.Empty:
|
|
55
|
+
break
|
|
56
|
+
self.dropped_frames += len(block)
|
|
57
|
+
if output is not None:
|
|
58
|
+
os.set_blocking(output.fileno(), False)
|
|
59
|
+
self.output = output
|
|
60
|
+
self.last_write = time.monotonic()
|
|
61
|
+
|
|
62
|
+
def callback(
|
|
63
|
+
self, data: np.ndarray, frames: int, timing: object, status: object
|
|
64
|
+
) -> None:
|
|
65
|
+
self.last_capture = time.monotonic()
|
|
66
|
+
block = (data[:, self.channel - 1 : self.channel + 1].copy(), str(status))
|
|
67
|
+
try:
|
|
68
|
+
self.blocks.put_nowait(block)
|
|
69
|
+
except queue.Full:
|
|
70
|
+
try:
|
|
71
|
+
discarded, _ = self.blocks.get_nowait()
|
|
72
|
+
self.overflow_frames += len(discarded)
|
|
73
|
+
except queue.Empty:
|
|
74
|
+
pass
|
|
75
|
+
self.blocks.put_nowait(block)
|
|
76
|
+
|
|
77
|
+
def update(self) -> None:
|
|
78
|
+
now = time.monotonic()
|
|
79
|
+
status = ''
|
|
80
|
+
if self.capture is not None and (
|
|
81
|
+
not self.capture.active or now - self.last_capture > 3
|
|
82
|
+
):
|
|
83
|
+
self.error = 'Audio capture stopped; retrying'
|
|
84
|
+
self.close_capture()
|
|
85
|
+
self.capture_retry.failed()
|
|
86
|
+
if (
|
|
87
|
+
self.capture is None
|
|
88
|
+
and self.capture_retry.seconds_until_attempt() == 0
|
|
89
|
+
and self.open_retry.begin_attempt()
|
|
90
|
+
):
|
|
91
|
+
try:
|
|
92
|
+
self.capture = sounddevice.InputStream(
|
|
93
|
+
device=self.device,
|
|
94
|
+
channels=self.channel + 1,
|
|
95
|
+
samplerate=self.sample_rate,
|
|
96
|
+
dtype='float32',
|
|
97
|
+
blocksize=1024,
|
|
98
|
+
callback=self.callback,
|
|
99
|
+
)
|
|
100
|
+
self.capture.start()
|
|
101
|
+
self.last_capture = now
|
|
102
|
+
self.open_retry.reset()
|
|
103
|
+
self.capture_retry.reset()
|
|
104
|
+
self.capture_retry.begin_attempt()
|
|
105
|
+
except (sounddevice.PortAudioError, OSError, ValueError):
|
|
106
|
+
self.error = 'Audio device unavailable; retrying'
|
|
107
|
+
self.close_capture()
|
|
108
|
+
self.open_retry.failed()
|
|
109
|
+
if not self.pending:
|
|
110
|
+
try:
|
|
111
|
+
block, status = self.blocks.get_nowait()
|
|
112
|
+
except queue.Empty:
|
|
113
|
+
block = None
|
|
114
|
+
status = ''
|
|
115
|
+
if block is not None:
|
|
116
|
+
if self.state.is_muted():
|
|
117
|
+
block.fill(0)
|
|
118
|
+
elif self.closing is not None:
|
|
119
|
+
block *= self.closing.audio_gain(now=time.time())
|
|
120
|
+
self.state.record_audio(
|
|
121
|
+
frames=len(block),
|
|
122
|
+
sample_rate=self.sample_rate,
|
|
123
|
+
left_level_db=level_db(block[:, 0]),
|
|
124
|
+
right_level_db=level_db(block[:, 1]),
|
|
125
|
+
clipping=bool(np.max(np.abs(block)) >= 1),
|
|
126
|
+
)
|
|
127
|
+
self.pending = block.tobytes()
|
|
128
|
+
if status:
|
|
129
|
+
self.error = f'Audio capture: {status}'
|
|
130
|
+
if self.pending and self.output is None:
|
|
131
|
+
self.dropped_frames += (len(self.pending) + 7) // 8
|
|
132
|
+
self.pending = b''
|
|
133
|
+
self.error = 'Encoder unavailable; discarding captured audio'
|
|
134
|
+
if self.pending and self.output is not None:
|
|
135
|
+
try:
|
|
136
|
+
written = os.write(self.output.fileno(), self.pending)
|
|
137
|
+
except BlockingIOError:
|
|
138
|
+
if now - self.last_write > 1:
|
|
139
|
+
self.error = 'FFmpeg audio input stalled; discarding old audio'
|
|
140
|
+
self.attach_output(self.output)
|
|
141
|
+
except OSError:
|
|
142
|
+
self.error = 'FFmpeg audio input closed'
|
|
143
|
+
else:
|
|
144
|
+
self.pending = self.pending[written:]
|
|
145
|
+
self.last_write = now
|
|
146
|
+
if not status and self.capture is not None:
|
|
147
|
+
self.error = None
|
|
148
|
+
self.state.set_audio_health(
|
|
149
|
+
self.error, self.dropped_frames + self.overflow_frames
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def close_capture(self) -> None:
|
|
153
|
+
if self.capture is not None:
|
|
154
|
+
capture, self.capture = self.capture, None
|
|
155
|
+
capture.close()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def level_db(samples: np.ndarray) -> float:
|
|
159
|
+
peak = float(np.max(np.abs(samples)))
|
|
160
|
+
return float(20 * np.log10(peak)) if peak > 0 else -120.0
|
streamo/auth.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import secrets
|
|
4
|
+
import time
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import webbrowser
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated, Literal, cast
|
|
10
|
+
|
|
11
|
+
import tyro
|
|
12
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
13
|
+
from pydantic import BaseModel, field_validator
|
|
14
|
+
|
|
15
|
+
from .credentials import write_private_toml
|
|
16
|
+
|
|
17
|
+
YOUTUBE_SCOPE = 'https://www.googleapis.com/auth/youtube.force-ssl'
|
|
18
|
+
KICK_AUTHORIZATION_URL = 'https://id.kick.com/oauth/authorize'
|
|
19
|
+
KICK_SCOPES = 'channel:read channel:write chat:write streamkey:read'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthOptions(BaseModel, frozen=True):
|
|
23
|
+
"""Authorize YouTube. For Kick, use `streamo auth kick --help`."""
|
|
24
|
+
|
|
25
|
+
service: Annotated[Literal['youtube'], tyro.conf.Positional]
|
|
26
|
+
client_secrets: Path
|
|
27
|
+
credentials: Path = Path('~/.config/streamo/youtube-auth.toml')
|
|
28
|
+
no_browser: bool = False
|
|
29
|
+
callback_port: int = 8765
|
|
30
|
+
|
|
31
|
+
@field_validator('callback_port')
|
|
32
|
+
@classmethod
|
|
33
|
+
def validate_callback_port(cls, value: int) -> int:
|
|
34
|
+
if not 1 <= value <= 65535:
|
|
35
|
+
raise ValueError('callback_port must be from 1 to 65535')
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class KickAuthOptions(BaseModel, frozen=True):
|
|
40
|
+
client_secrets: Path
|
|
41
|
+
credentials: Path = Path('~/.config/streamo/kick-auth.toml')
|
|
42
|
+
no_browser: bool = False
|
|
43
|
+
callback_port: int = 8765
|
|
44
|
+
|
|
45
|
+
@field_validator('callback_port')
|
|
46
|
+
@classmethod
|
|
47
|
+
def validate_callback_port(cls, value: int) -> int:
|
|
48
|
+
if not 1 <= value <= 65535:
|
|
49
|
+
raise ValueError('callback_port must be from 1 to 65535')
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class KickCallbackServer(HTTPServer):
|
|
54
|
+
expected_state: str
|
|
55
|
+
code: str | None
|
|
56
|
+
error: str | None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class KickCallbackHandler(BaseHTTPRequestHandler):
|
|
60
|
+
def do_GET(self) -> None:
|
|
61
|
+
server = cast(KickCallbackServer, self.server)
|
|
62
|
+
url = urllib.parse.urlsplit(self.path)
|
|
63
|
+
query = urllib.parse.parse_qs(url.query)
|
|
64
|
+
state = first_query_value(query, 'state')
|
|
65
|
+
if url.path != '/' or state != server.expected_state:
|
|
66
|
+
message = 'This request is not the expected Kick authorization callback.'
|
|
67
|
+
status = 400
|
|
68
|
+
elif error := first_query_value(query, 'error'):
|
|
69
|
+
server.error = f'Kick authorization failed: {error}'
|
|
70
|
+
message = server.error
|
|
71
|
+
status = 400
|
|
72
|
+
elif code := first_query_value(query, 'code'):
|
|
73
|
+
server.code = code
|
|
74
|
+
message = 'Kick authorization complete. You can close this window.'
|
|
75
|
+
status = 200
|
|
76
|
+
else:
|
|
77
|
+
server.error = 'Kick authorization returned no code'
|
|
78
|
+
message = server.error
|
|
79
|
+
status = 400
|
|
80
|
+
body = message.encode()
|
|
81
|
+
self.send_response(status)
|
|
82
|
+
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
|
83
|
+
self.send_header('Content-Length', str(len(body)))
|
|
84
|
+
self.end_headers()
|
|
85
|
+
self.wfile.write(body)
|
|
86
|
+
|
|
87
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main(argv: list[str] | None = None) -> int:
|
|
92
|
+
if argv is not None and argv[:1] == ['kick']:
|
|
93
|
+
options = tyro.cli(KickAuthOptions, args=argv[1:])
|
|
94
|
+
authorize_kick(options)
|
|
95
|
+
return 0
|
|
96
|
+
options = tyro.cli(AuthOptions, args=argv)
|
|
97
|
+
authorize_youtube(options)
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def authorize_youtube(options: AuthOptions) -> None:
|
|
102
|
+
flow = InstalledAppFlow.from_client_secrets_file(
|
|
103
|
+
options.client_secrets, scopes=[YOUTUBE_SCOPE]
|
|
104
|
+
)
|
|
105
|
+
credentials = flow.run_local_server(
|
|
106
|
+
host='127.0.0.1',
|
|
107
|
+
port=options.callback_port,
|
|
108
|
+
open_browser=not options.no_browser,
|
|
109
|
+
access_type='offline',
|
|
110
|
+
prompt='consent',
|
|
111
|
+
)
|
|
112
|
+
if (
|
|
113
|
+
credentials.client_id is None
|
|
114
|
+
or credentials.client_secret is None
|
|
115
|
+
or credentials.refresh_token is None
|
|
116
|
+
):
|
|
117
|
+
raise ValueError('Google did not return reusable YouTube credentials')
|
|
118
|
+
write_private_toml(
|
|
119
|
+
options.credentials,
|
|
120
|
+
{
|
|
121
|
+
'client_id': credentials.client_id,
|
|
122
|
+
'client_secret': credentials.client_secret,
|
|
123
|
+
'refresh_token': credentials.refresh_token,
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
print(f'Stored YouTube credentials in {options.credentials.expanduser()}')
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def authorize_kick(options: KickAuthOptions) -> None:
|
|
130
|
+
from .kick_api import KickClientSecrets, exchange_kick_code, load_toml_model
|
|
131
|
+
|
|
132
|
+
client = load_toml_model(
|
|
133
|
+
options.client_secrets, KickClientSecrets, 'Kick client secrets'
|
|
134
|
+
)
|
|
135
|
+
verifier = secrets.token_urlsafe(64)
|
|
136
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
|
|
137
|
+
challenge_text = challenge.rstrip(b'=').decode()
|
|
138
|
+
state = secrets.token_urlsafe(32)
|
|
139
|
+
redirect_uri = f'http://127.0.0.1:{options.callback_port}/'
|
|
140
|
+
query = urllib.parse.urlencode(
|
|
141
|
+
{
|
|
142
|
+
'response_type': 'code',
|
|
143
|
+
'client_id': client.client_id,
|
|
144
|
+
'redirect_uri': redirect_uri,
|
|
145
|
+
'scope': KICK_SCOPES,
|
|
146
|
+
'code_challenge': challenge_text,
|
|
147
|
+
'code_challenge_method': 'S256',
|
|
148
|
+
'state': state,
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
authorization_url = f'{KICK_AUTHORIZATION_URL}?{query}'
|
|
152
|
+
code = receive_kick_code(
|
|
153
|
+
authorization_url,
|
|
154
|
+
state,
|
|
155
|
+
callback_port=options.callback_port,
|
|
156
|
+
open_browser=not options.no_browser,
|
|
157
|
+
)
|
|
158
|
+
token = exchange_kick_code(client, code, verifier, redirect_uri)
|
|
159
|
+
write_private_toml(
|
|
160
|
+
options.credentials,
|
|
161
|
+
{
|
|
162
|
+
'client_id': client.client_id,
|
|
163
|
+
'client_secret': client.client_secret.get_secret_value(),
|
|
164
|
+
'refresh_token': token.refresh_token.get_secret_value(),
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
print(f'Stored Kick credentials in {options.credentials.expanduser()}')
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def receive_kick_code(
|
|
171
|
+
authorization_url: str,
|
|
172
|
+
state: str,
|
|
173
|
+
*,
|
|
174
|
+
callback_port: int,
|
|
175
|
+
open_browser: bool,
|
|
176
|
+
) -> str:
|
|
177
|
+
server = KickCallbackServer(('127.0.0.1', callback_port), KickCallbackHandler)
|
|
178
|
+
server.expected_state = state
|
|
179
|
+
server.code = None
|
|
180
|
+
server.error = None
|
|
181
|
+
deadline = time.monotonic() + 300
|
|
182
|
+
try:
|
|
183
|
+
print(f'Open this URL to authorize streamO:\n\n{authorization_url}\n')
|
|
184
|
+
if open_browser:
|
|
185
|
+
webbrowser.open(authorization_url)
|
|
186
|
+
while server.code is None and server.error is None:
|
|
187
|
+
if (remaining := deadline - time.monotonic()) <= 0:
|
|
188
|
+
raise ValueError('Kick authorization timed out after 5 minutes')
|
|
189
|
+
server.timeout = min(1, remaining)
|
|
190
|
+
server.handle_request()
|
|
191
|
+
except KeyboardInterrupt:
|
|
192
|
+
raise SystemExit('Kick authorization interrupted') from None
|
|
193
|
+
finally:
|
|
194
|
+
server.server_close()
|
|
195
|
+
if server.error is not None:
|
|
196
|
+
raise ValueError(server.error)
|
|
197
|
+
if server.code is None:
|
|
198
|
+
raise ValueError('Kick authorization returned no code')
|
|
199
|
+
return server.code
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def first_query_value(values: dict[str, list[str]], name: str) -> str | None:
|
|
203
|
+
items = values.get(name)
|
|
204
|
+
return items[0] if items else None
|
streamo/closing.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""A durable closing operation driven by the program's wall clock."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
11
|
+
from reccy.configuration import units
|
|
12
|
+
from reccy.runtime.files import atomic_output
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CreditPage(BaseModel, frozen=True):
|
|
16
|
+
text: str | None = None
|
|
17
|
+
image: Path | None = None
|
|
18
|
+
visible_seconds: units.Seconds = Field(gt=0)
|
|
19
|
+
fade_in_seconds: units.Seconds = Field(gt=0)
|
|
20
|
+
fade_out_seconds: units.Seconds = Field(gt=0)
|
|
21
|
+
|
|
22
|
+
@model_validator(mode='after')
|
|
23
|
+
def one_source(self) -> 'CreditPage':
|
|
24
|
+
if (self.text is None) == (self.image is None):
|
|
25
|
+
raise ValueError('each credit page needs exactly one text or image')
|
|
26
|
+
if self.text is not None and not self.text.strip():
|
|
27
|
+
raise ValueError('credit page text cannot be empty')
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def duration(self) -> float:
|
|
32
|
+
return self.fade_in_seconds + self.visible_seconds + self.fade_out_seconds
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(extra='forbid')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ClosingCredits(BaseModel, frozen=True):
|
|
38
|
+
pages: list[CreditPage] = Field(min_length=1)
|
|
39
|
+
audio_fade_curve: Literal['linear', 'equal_power'] = 'linear'
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(extra='forbid')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
DEFAULT_CREDITS = ClosingCredits(
|
|
45
|
+
pages=[
|
|
46
|
+
CreditPage(
|
|
47
|
+
text='The humans made the music',
|
|
48
|
+
visible_seconds=3,
|
|
49
|
+
fade_in_seconds=1,
|
|
50
|
+
fade_out_seconds=1,
|
|
51
|
+
),
|
|
52
|
+
CreditPage(
|
|
53
|
+
text='The machines merely helped',
|
|
54
|
+
visible_seconds=3,
|
|
55
|
+
fade_in_seconds=1,
|
|
56
|
+
fade_out_seconds=1,
|
|
57
|
+
),
|
|
58
|
+
CreditPage(
|
|
59
|
+
text='Thank you for listening',
|
|
60
|
+
visible_seconds=3,
|
|
61
|
+
fade_in_seconds=1,
|
|
62
|
+
fade_out_seconds=1,
|
|
63
|
+
),
|
|
64
|
+
]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ClosingSequence:
|
|
69
|
+
def __init__(self, config: ClosingCredits | None, state_path: Path) -> None:
|
|
70
|
+
self.config = config
|
|
71
|
+
self.state_path = state_path
|
|
72
|
+
self.lock = threading.Lock()
|
|
73
|
+
self.operation_id: str | None = None
|
|
74
|
+
self.state = 'idle'
|
|
75
|
+
self.started_at: float | None = None
|
|
76
|
+
self.black_started_at: float | None = None
|
|
77
|
+
self.error: str | None = None
|
|
78
|
+
self._load()
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def page_end_seconds(self) -> float:
|
|
82
|
+
if self.config is None:
|
|
83
|
+
return 0.0
|
|
84
|
+
return sum(p.duration for p in self.config.pages)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def duration_seconds(self) -> float:
|
|
88
|
+
return self.page_end_seconds + 4.0
|
|
89
|
+
|
|
90
|
+
def start(
|
|
91
|
+
self, operation_id: str, *, now: float | None = None
|
|
92
|
+
) -> dict[str, object]:
|
|
93
|
+
if self.config is None:
|
|
94
|
+
raise ValueError('closing_credits pages are not configured')
|
|
95
|
+
if not operation_id or len(operation_id) > 100:
|
|
96
|
+
raise ValueError('operation_id must contain 1 to 100 characters')
|
|
97
|
+
with self.lock:
|
|
98
|
+
if self.state == 'running':
|
|
99
|
+
raise ValueError('a closing sequence is already running')
|
|
100
|
+
self.operation_id = operation_id
|
|
101
|
+
self.state = 'running'
|
|
102
|
+
self.started_at = time.time() if now is None else now
|
|
103
|
+
self.black_started_at = None
|
|
104
|
+
self.error = None
|
|
105
|
+
try:
|
|
106
|
+
self._save()
|
|
107
|
+
except OSError:
|
|
108
|
+
self.state = 'failed'
|
|
109
|
+
raise
|
|
110
|
+
return self._snapshot(self.started_at)
|
|
111
|
+
|
|
112
|
+
def snapshot(self, *, now: float | None = None) -> dict[str, object]:
|
|
113
|
+
with self.lock:
|
|
114
|
+
return self._snapshot(time.time() if now is None else now)
|
|
115
|
+
|
|
116
|
+
def _snapshot(self, now: float) -> dict[str, object]:
|
|
117
|
+
elapsed = (
|
|
118
|
+
max(0.0, now - self.started_at) if self.started_at is not None else 0.0
|
|
119
|
+
)
|
|
120
|
+
page, _, phase = self.visual(elapsed)
|
|
121
|
+
return {
|
|
122
|
+
'operation_id': self.operation_id,
|
|
123
|
+
'state': self.state,
|
|
124
|
+
'phase': phase if self.state == 'running' else self.state,
|
|
125
|
+
'page': page,
|
|
126
|
+
'page_count': len(self.config.pages) if self.config is not None else 0,
|
|
127
|
+
'elapsed_seconds': elapsed,
|
|
128
|
+
'duration_seconds': self.duration_seconds,
|
|
129
|
+
'started_at': self.started_at,
|
|
130
|
+
'black_started_at': self.black_started_at,
|
|
131
|
+
'black_at': self.started_at + self.page_end_seconds + 2
|
|
132
|
+
if self.started_at is not None
|
|
133
|
+
else None,
|
|
134
|
+
'error': self.error,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
def visual(self, elapsed: float) -> tuple[int | None, float, str]:
|
|
138
|
+
if self.config is None:
|
|
139
|
+
return None, 0.0, 'idle'
|
|
140
|
+
position = 0.0
|
|
141
|
+
for index, page in enumerate(self.config.pages, 1):
|
|
142
|
+
if elapsed < position + page.duration:
|
|
143
|
+
within = elapsed - position
|
|
144
|
+
opacity = min(
|
|
145
|
+
1.0,
|
|
146
|
+
max(0.0, within / page.fade_in_seconds),
|
|
147
|
+
max(0.0, (page.duration - within) / page.fade_out_seconds),
|
|
148
|
+
)
|
|
149
|
+
return index, opacity, 'page'
|
|
150
|
+
position += page.duration
|
|
151
|
+
if elapsed < position + 2:
|
|
152
|
+
return None, 0.0, 'video-fade'
|
|
153
|
+
return None, 1.0, 'black'
|
|
154
|
+
|
|
155
|
+
def video_black_opacity(self, elapsed: float) -> float:
|
|
156
|
+
return max(0.0, min(1.0, (elapsed - self.page_end_seconds) / 2))
|
|
157
|
+
|
|
158
|
+
def audio_gain(self, *, now: float | None = None) -> float:
|
|
159
|
+
with self.lock:
|
|
160
|
+
if (
|
|
161
|
+
self.state != 'running'
|
|
162
|
+
or self.started_at is None
|
|
163
|
+
or self.config is None
|
|
164
|
+
):
|
|
165
|
+
return 1.0
|
|
166
|
+
elapsed = (time.time() if now is None else now) - self.started_at
|
|
167
|
+
progress = max(0.0, min(1.0, elapsed / (self.page_end_seconds + 2)))
|
|
168
|
+
if self.config.audio_fade_curve == 'equal_power':
|
|
169
|
+
return math.cos(progress * math.pi / 2)
|
|
170
|
+
return 1.0 - progress
|
|
171
|
+
|
|
172
|
+
def tick(self, *, now: float | None = None) -> bool:
|
|
173
|
+
with self.lock:
|
|
174
|
+
if self.state != 'running' or self.started_at is None:
|
|
175
|
+
return False
|
|
176
|
+
current = time.time() if now is None else now
|
|
177
|
+
if (
|
|
178
|
+
self.black_started_at is None
|
|
179
|
+
and current >= self.started_at + self.page_end_seconds + 2
|
|
180
|
+
):
|
|
181
|
+
self.black_started_at = current
|
|
182
|
+
self._save()
|
|
183
|
+
return current >= self.started_at + self.duration_seconds
|
|
184
|
+
|
|
185
|
+
def complete(self) -> None:
|
|
186
|
+
with self.lock:
|
|
187
|
+
if self.state == 'running':
|
|
188
|
+
self.state = 'completed'
|
|
189
|
+
self._save()
|
|
190
|
+
|
|
191
|
+
def fail(self, error: str) -> None:
|
|
192
|
+
with self.lock:
|
|
193
|
+
if self.state == 'running':
|
|
194
|
+
self.state = 'failed'
|
|
195
|
+
self.error = error
|
|
196
|
+
self._save()
|
|
197
|
+
|
|
198
|
+
def _load(self) -> None:
|
|
199
|
+
try:
|
|
200
|
+
value = json.loads(self.state_path.read_text())
|
|
201
|
+
except FileNotFoundError:
|
|
202
|
+
return
|
|
203
|
+
self.operation_id = value['operation_id']
|
|
204
|
+
self.state = value['state']
|
|
205
|
+
self.started_at = value['started_at']
|
|
206
|
+
self.black_started_at = value['black_started_at']
|
|
207
|
+
self.error = value['error']
|
|
208
|
+
if self.state == 'running':
|
|
209
|
+
self.state = 'failed'
|
|
210
|
+
self.error = (
|
|
211
|
+
'streamO restarted during closing credits; '
|
|
212
|
+
'broadcast outcome is uncertain'
|
|
213
|
+
)
|
|
214
|
+
self._save()
|
|
215
|
+
|
|
216
|
+
def _save(self) -> None:
|
|
217
|
+
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
with atomic_output(self.state_path) as temporary:
|
|
219
|
+
temporary.write_text(
|
|
220
|
+
json.dumps(
|
|
221
|
+
{
|
|
222
|
+
'operation_id': self.operation_id,
|
|
223
|
+
'state': self.state,
|
|
224
|
+
'started_at': self.started_at,
|
|
225
|
+
'black_started_at': self.black_started_at,
|
|
226
|
+
'error': self.error,
|
|
227
|
+
}
|
|
228
|
+
)
|
|
229
|
+
)
|