solana-agent 31.1.7__py3-none-any.whl → 31.2.1__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.
@@ -0,0 +1,282 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import logging
6
+ from typing import List, AsyncGenerator
7
+
8
+ from solana_agent.interfaces.providers.audio import AudioTranscoder
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class FFmpegTranscoder(AudioTranscoder):
14
+ """FFmpeg-based transcoder. Requires 'ffmpeg' binary in PATH.
15
+
16
+ This uses subprocess to stream bytes through ffmpeg for encode/decode.
17
+ """
18
+
19
+ async def _run_ffmpeg(
20
+ self, args: List[str], data: bytes
21
+ ) -> bytes: # pragma: no cover
22
+ logger.info("FFmpeg: starting process args=%s, input_len=%d", args, len(data))
23
+ proc = await asyncio.create_subprocess_exec(
24
+ "ffmpeg",
25
+ *args,
26
+ stdin=asyncio.subprocess.PIPE,
27
+ stdout=asyncio.subprocess.PIPE,
28
+ stderr=asyncio.subprocess.PIPE,
29
+ )
30
+ stdout, stderr = await proc.communicate(input=data)
31
+ if proc.returncode != 0:
32
+ err = (stderr or b"").decode("utf-8", errors="ignore")
33
+ logger.error("FFmpeg failed (code=%s): %s", proc.returncode, err[:2000])
34
+ raise RuntimeError("ffmpeg failed to transcode audio")
35
+ logger.info("FFmpeg: finished successfully, output_len=%d", len(stdout or b""))
36
+ if stderr:
37
+ logger.debug(
38
+ "FFmpeg stderr: %s", stderr.decode("utf-8", errors="ignore")[:2000]
39
+ )
40
+ return stdout
41
+
42
+ async def to_pcm16( # pragma: no cover
43
+ self, audio_bytes: bytes, input_mime: str, rate_hz: int
44
+ ) -> bytes:
45
+ """Decode compressed audio to mono PCM16LE at rate_hz."""
46
+ logger.info(
47
+ "Transcode to PCM16: input_mime=%s, rate_hz=%d, input_len=%d",
48
+ input_mime,
49
+ rate_hz,
50
+ len(audio_bytes),
51
+ )
52
+ # Prefer to hint format for common containers/codecs; ffmpeg can still autodetect if hint is wrong.
53
+ hinted_format = None
54
+ if input_mime in ("audio/mp4", "audio/m4a"):
55
+ hinted_format = "mp4"
56
+ elif input_mime in ("audio/aac",):
57
+ # Raw AAC is typically in ADTS stream format
58
+ hinted_format = "adts"
59
+ elif input_mime in ("audio/ogg", "audio/webm"):
60
+ hinted_format = None # container detection is decent here
61
+ elif input_mime in ("audio/wav", "audio/x-wav"):
62
+ hinted_format = "wav"
63
+
64
+ args = [
65
+ "-hide_banner",
66
+ "-loglevel",
67
+ "error",
68
+ ]
69
+ if hinted_format:
70
+ args += ["-f", hinted_format]
71
+ args += [
72
+ "-i",
73
+ "pipe:0",
74
+ "-acodec",
75
+ "pcm_s16le",
76
+ "-ac",
77
+ "1",
78
+ "-ar",
79
+ str(rate_hz),
80
+ "-f",
81
+ "s16le",
82
+ "pipe:1",
83
+ ]
84
+ out = await self._run_ffmpeg(args, audio_bytes)
85
+ logger.info("Transcoded to PCM16: output_len=%d", len(out))
86
+ return out
87
+
88
+ async def from_pcm16( # pragma: no cover
89
+ self, pcm16_bytes: bytes, output_mime: str, rate_hz: int
90
+ ) -> bytes:
91
+ """Encode PCM16LE to desired format (currently AAC ADTS for mobile streaming)."""
92
+ logger.info(
93
+ "Encode from PCM16: output_mime=%s, rate_hz=%d, input_len=%d",
94
+ output_mime,
95
+ rate_hz,
96
+ len(pcm16_bytes),
97
+ )
98
+ if output_mime in ("audio/mpeg", "audio/mp3"):
99
+ # Encode to MP3 (often better streaming compatibility on mobile)
100
+ args = [
101
+ "-hide_banner",
102
+ "-loglevel",
103
+ "error",
104
+ "-f",
105
+ "s16le",
106
+ "-ac",
107
+ "1",
108
+ "-ar",
109
+ str(rate_hz),
110
+ "-i",
111
+ "pipe:0",
112
+ "-c:a",
113
+ "libmp3lame",
114
+ "-b:a",
115
+ "128k",
116
+ "-f",
117
+ "mp3",
118
+ "pipe:1",
119
+ ]
120
+ out = await self._run_ffmpeg(args, pcm16_bytes)
121
+ logger.info(
122
+ "Encoded from PCM16 to %s: output_len=%d", output_mime, len(out)
123
+ )
124
+ return out
125
+ if output_mime in ("audio/aac", "audio/mp4", "audio/m4a"):
126
+ # Encode to AAC in ADTS stream; clients can play it as AAC.
127
+ args = [
128
+ "-hide_banner",
129
+ "-loglevel",
130
+ "error",
131
+ "-f",
132
+ "s16le",
133
+ "-ac",
134
+ "1",
135
+ "-ar",
136
+ str(rate_hz),
137
+ "-i",
138
+ "pipe:0",
139
+ "-c:a",
140
+ "aac",
141
+ "-b:a",
142
+ "96k",
143
+ "-f",
144
+ "adts",
145
+ "pipe:1",
146
+ ]
147
+ out = await self._run_ffmpeg(args, pcm16_bytes)
148
+ logger.info(
149
+ "Encoded from PCM16 to %s: output_len=%d", output_mime, len(out)
150
+ )
151
+ return out
152
+ # Default: passthrough
153
+ logger.info("Encode passthrough (no change), output_len=%d", len(pcm16_bytes))
154
+ return pcm16_bytes
155
+
156
+ async def stream_from_pcm16( # pragma: no cover
157
+ self,
158
+ pcm_iter: AsyncGenerator[bytes, None],
159
+ output_mime: str,
160
+ rate_hz: int,
161
+ read_chunk_size: int = 4096,
162
+ ) -> AsyncGenerator[bytes, None]:
163
+ """Start a single continuous encoder and stream encoded audio chunks.
164
+
165
+ - Launches one ffmpeg subprocess for the entire response.
166
+ - Feeds PCM16LE mono bytes from pcm_iter into stdin.
167
+ - Yields encoded bytes from stdout as they become available.
168
+ """
169
+ if output_mime in ("audio/mpeg", "audio/mp3"):
170
+ args = [
171
+ "-hide_banner",
172
+ "-loglevel",
173
+ "error",
174
+ "-f",
175
+ "s16le",
176
+ "-ac",
177
+ "1",
178
+ "-ar",
179
+ str(rate_hz),
180
+ "-i",
181
+ "pipe:0",
182
+ "-c:a",
183
+ "libmp3lame",
184
+ "-b:a",
185
+ "128k",
186
+ "-f",
187
+ "mp3",
188
+ "pipe:1",
189
+ ]
190
+ elif output_mime in ("audio/aac", "audio/mp4", "audio/m4a"):
191
+ args = [
192
+ "-hide_banner",
193
+ "-loglevel",
194
+ "error",
195
+ "-f",
196
+ "s16le",
197
+ "-ac",
198
+ "1",
199
+ "-ar",
200
+ str(rate_hz),
201
+ "-i",
202
+ "pipe:0",
203
+ "-c:a",
204
+ "aac",
205
+ "-b:a",
206
+ "96k",
207
+ "-f",
208
+ "adts",
209
+ "pipe:1",
210
+ ]
211
+ else:
212
+ # Passthrough streaming: just yield input
213
+ async for chunk in pcm_iter:
214
+ yield chunk
215
+ return
216
+
217
+ logger.info("FFmpeg(stream): starting args=%s", args)
218
+ proc = await asyncio.create_subprocess_exec(
219
+ "ffmpeg",
220
+ *args,
221
+ stdin=asyncio.subprocess.PIPE,
222
+ stdout=asyncio.subprocess.PIPE,
223
+ stderr=asyncio.subprocess.PIPE,
224
+ )
225
+
226
+ assert proc.stdin is not None and proc.stdout is not None
227
+
228
+ async def _writer():
229
+ try:
230
+ async for pcm in pcm_iter:
231
+ if not pcm:
232
+ continue
233
+ proc.stdin.write(pcm)
234
+ # Backpressure
235
+ await proc.stdin.drain()
236
+ except asyncio.CancelledError:
237
+ # Swallow cancellation; stdin will be closed below.
238
+ pass
239
+ except Exception as e:
240
+ logger.debug("FFmpeg(stream) writer error: %s", str(e))
241
+ finally:
242
+ with contextlib.suppress(Exception):
243
+ proc.stdin.close()
244
+
245
+ writer_task = asyncio.create_task(_writer())
246
+
247
+ buf = bytearray()
248
+ try:
249
+ while True:
250
+ data = await proc.stdout.read(read_chunk_size)
251
+ if not data:
252
+ break
253
+ buf.extend(data)
254
+ # Emit fixed-size chunks even if read returns a larger blob
255
+ while len(buf) >= read_chunk_size:
256
+ yield bytes(buf[:read_chunk_size])
257
+ del buf[:read_chunk_size]
258
+ # Flush any remainder
259
+ if buf:
260
+ yield bytes(buf)
261
+ finally:
262
+ # Ensure writer is done
263
+ if not writer_task.done():
264
+ with contextlib.suppress(Exception):
265
+ writer_task.cancel()
266
+ try:
267
+ await writer_task
268
+ except asyncio.CancelledError:
269
+ pass
270
+ except Exception:
271
+ pass
272
+ # Drain remaining stderr and check return code
273
+ try:
274
+ stderr = await proc.stderr.read() if proc.stderr else b""
275
+ code = await proc.wait()
276
+ if code != 0:
277
+ err = (stderr or b"").decode("utf-8", errors="ignore")
278
+ logger.error(
279
+ "FFmpeg(stream) failed (code=%s): %s", code, err[:2000]
280
+ )
281
+ except Exception:
282
+ pass
@@ -56,6 +56,7 @@ class OpenAIAdapter(LLMProvider):
56
56
  """OpenAI implementation of LLMProvider with web search capabilities."""
57
57
 
58
58
  def __init__(self, api_key: str, logfire_api_key: Optional[str] = None):
59
+ self.api_key = api_key
59
60
  self.client = AsyncOpenAI(api_key=api_key)
60
61
 
61
62
  self.logfire = False
@@ -76,6 +77,10 @@ class OpenAIAdapter(LLMProvider):
76
77
  self.embedding_model = DEFAULT_EMBEDDING_MODEL
77
78
  self.embedding_dimensions = DEFAULT_EMBEDDING_DIMENSIONS
78
79
 
80
+ def get_api_key(self) -> Optional[str]: # pragma: no cover
81
+ """Return the API key used to configure the OpenAI client."""
82
+ return getattr(self, "api_key", None)
83
+
79
84
  async def tts(
80
85
  self,
81
86
  text: str,