Skip to content

Commit ff08ba9

Browse files
allow specifying source ffmpeg format (#102)
A requested feature in music assistant is a music provider for [local audio input](https://github.com/orgs/music-assistant/discussions/1156). I figured why not also put that ability in to the sendspin serve cli for a stand alone way of doing it without music assistant. Because pyav is used to load the input source we can do that just by specifying the ffmpeg input source format along with the source. Steps to get it running: 1. `pip install --no-binary av .` (necessary to use OS installed ffmpeg for alsa/pulse devices) 2. `sendspin serve --source-format pulse default` (or `sendspin serve --source-format alsa hw:0`) Latency seems to be about 5 seconds on my machine but it is better than nothing! You can even get it to stream from your phone's bluetooth paired to the computer with a few more steps: 1. pair the phone to the computer and select it as the media output device on your phone 2. create a virtual output device: `pactl load-module module-null-sink sink_name=sendspin` 3. use pavucontrol's playback tab to select the new sendspin output device as the playback "hardware" for your bluetooth stream (show all streams at bottom first) 4. `sendspin serve --source-format pulse sendspin.monitor`
1 parent 0aec4b6 commit ff08ba9

5 files changed

Lines changed: 16 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,8 @@ Settings are stored in `~/.config/sendspin/`:
154154
| `use_mpris` | boolean | TUI/daemon | Enable MPRIS integration (default: true) |
155155
| `hook_start` | string | TUI/daemon | Command to run when audio stream starts |
156156
| `hook_stop` | string | TUI/daemon | Command to run when audio stream stops |
157-
| `source` | string | serve | Default audio source (file path or URL) |
157+
| `source` | string | serve | Default audio source (file path or URL, ffmpeg input) |
158+
| `source_format` | string | serve | ffmpeg container format for audio source |
158159
| `clients` | array | serve | Client URLs to connect to (`--client`) |
159160

160161
Settings are automatically saved when changed through the TUI. You can also edit the JSON file directly while the client is not running.

‎sendspin/cli.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
7575
default=None,
7676
help="Audio source: local file path or URL (http/https)",
7777
)
78+
serve_parser.add_argument(
79+
"--source-format",
80+
default=None,
81+
help="ffmpeg container format for source audio",
82+
)
7883
serve_parser.add_argument(
7984
"--demo",
8085
action="store_true",
@@ -392,6 +397,7 @@ async def _run_serve_mode(args: argparse.Namespace) -> int:
392397

393398
serve_config = ServeConfig(
394399
source=source,
400+
source_format=args.source_format or settings.source_format,
395401
port=args.port,
396402
name=args.name,
397403
clients=args.clients or settings.clients,

‎sendspin/serve/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class ServeConfig:
6363
"""Configuration for the serve command."""
6464

6565
source: str
66+
source_format: str | None = None
6667
port: int = 8928
6768
name: str = "Sendspin Server"
6869
clients: list[str] | None = None
@@ -220,7 +221,7 @@ def on_server_event(server: SendspinServer, event: SendspinEvent) -> None:
220221

221222
# Decode and stream audio
222223
try:
223-
audio_source = await decode_audio(config.source)
224+
audio_source = await decode_audio(config.source, source_format=config.source_format)
224225
media_stream = MediaStream(
225226
main_channel_source=audio_source.generator,
226227
main_channel_format=audio_source.format,

‎sendspin/serve/source.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ async def decode_audio(
5050
*,
5151
target_sample_rate: int = 48000,
5252
target_channels: int = 2,
53+
source_format: str | None = None,
5354
) -> AudioSource:
5455
"""
5556
Decode an audio source (file path or URL) to PCM.
@@ -78,7 +79,7 @@ async def pcm_generator() -> AsyncGenerator[bytes, None]:
7879
if container is not None:
7980
container.close()
8081

81-
container = av.open(source)
82+
container = av.open(format=source_format, file=source)
8283
resampler = av.AudioResampler(format="s16", layout=layout, rate=target_sample_rate)
8384
for frame in container.decode(container.streams.audio[0]):
8485
for resampled in resampler.resample(frame):

‎sendspin/settings.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ class ServeSettings(BaseSettings):
201201
"""Settings for serve mode."""
202202

203203
source: str | None = None
204+
source_format: str | None = None
204205
clients: list[str] | None = None
205206

206207
def update(
@@ -210,6 +211,7 @@ def update(
210211
log_level: str | None = None,
211212
listen_port: int | None = None,
212213
source: str | None = None,
214+
source_format: str | None = None,
213215
clients: list[str] | None = None,
214216
) -> None:
215217
"""Update settings fields. Only changed fields trigger a save."""
@@ -219,6 +221,7 @@ def update(
219221
"log_level": log_level,
220222
"listen_port": listen_port,
221223
"source": source,
224+
"source_format": source_format,
222225
"clients": clients,
223226
}
224227
)
@@ -238,6 +241,7 @@ def _load(self) -> None:
238241
self.log_level = data.get("log_level")
239242
self.listen_port = data.get("listen_port")
240243
self.source = data.get("source")
244+
self.source_format = data.get("source_format")
241245
self.clients = data.get("clients")
242246
logger.info("Loaded settings from %s", self._settings_file)
243247
except (json.JSONDecodeError, OSError) as e:

0 commit comments

Comments
 (0)