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 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
+ )