Skip to content

Commit

Permalink
Merge pull request #11 from ugjka/wav
Browse files Browse the repository at this point in the history
add option to stream in raw/lossless formats and ability to set endianess
  • Loading branch information
ugjka committed Feb 17, 2024
2 parents 75a0568 + fd628a2 commit 7a53729
Show file tree
Hide file tree
Showing 7 changed files with 322 additions and 110 deletions.
40 changes: 33 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,29 +44,55 @@ Select the lan IP address for the stream:
2023/07/08 23:53:07 setting av1transport URI and playing
```

There's also a `-debug` and `-headers` flags if you want to inspect your DLNA device
There's also a `-debug` and `-headers` flags if you want to inspect your DLNA device. Also `-log` to inspect what parec and ffmpeg is doing.

### Flags for scripting
### Non-interactive usage and extra flags

```
[ugjka@ugjka blast]$ blast -h
Usage of blast:
-bige
use big endian for capture and raw formats
-bitrate int
mp3 bitrate (default 320)
audio format bitrate (default 320)
-bits int
audio bitdepth (default 16)
-channels int
audio channels (default 2)
-chunk int
chunk size in seconds (default 1)
chunked size in seconds (default 1)
-debug
print debug info
-device string
dlna friendly name
dlna device's friendly name
-dummy
only serve content
-format string
stream audio format (default "mp3")
-headers
print request headers
-ip string
ip address
host ip address
-log
log parec and ffmpeg stderr
-mime string
stream mime type (default "audio/mpeg")
-nochunked
disable chunked tranfer endcoding
-port int
stream port (default 9000)
-rate int
audio sample rate (default 44100)
-source string
audio source (pactl list sources short | cut -f2)
-useaac
use aac audio
-useflac
use flac audio
-uselpcm
use lpcm audio
-usewav
use wav audio
```

## Building
Expand Down Expand Up @@ -96,7 +122,7 @@ This is for pipewire-pulse users.

## Caveats

* You need to allow port 9000 from LAN for the DLNA receiver to be able to access the HTTP stream
* You need to allow port 9000 from LAN for the DLNA receiver to be able to access the HTTP stream, you can change it with `-port` flag
* blast monitor sink may not be visible in the pulse control applet unless you enable virtual streams

## Trivia
Expand Down
157 changes: 129 additions & 28 deletions audio_serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,34 @@ package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"slices"
"strings"
"sync"

"github.com/davecgh/go-spew/spew"
)

type source string
type stream struct {
sink string
mime string
format string
bitrate int
chunk int
printheaders bool
contentfeat dlnaContentFeatures
bitdepth int
samplerate int
channels int
nochunked bool
bige bool
}

func (s source) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if *headers {
func (s stream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if s.printheaders {
spew.Fdump(os.Stderr, r.Proto)
spew.Fdump(os.Stderr, r.RemoteAddr)
spew.Fdump(os.Stderr, r.URL)
Expand All @@ -51,30 +68,27 @@ func (s source) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Add("User-Agent", "Blast-DLNA UPnP/1.0 DLNADOC/1.50")
// handle devices like Samsung TVs
if r.Header.Get("GetContentFeatures.DLNA.ORG") == "1" {
f := dlnaContentFeatures{
profileName: "MP3",
supportTimeSeek: false,
supportRange: false,
flags: DLNA_ORG_FLAG_DLNA_V15 |
DLNA_ORG_FLAG_CONNECTION_STALL |
DLNA_ORG_FLAG_STREAMING_TRANSFER_MODE |
DLNA_ORG_FLAG_BACKGROUND_TRANSFERT_MODE,
}
w.Header().Set("ContentFeatures.DLNA.ORG", f.String())
w.Header().Set("ContentFeatures.DLNA.ORG", s.contentfeat.String())
}

var yearSeconds = 365 * 24 * 60 * 60
if r.Header.Get("Getmediainfo.sec") == "1" {
w.Header().Set("MediaInfo.sec", fmt.Sprintf("SEC_Duration=%d", yearSeconds*1000))
}
w.Header().Add("Content-Type", "audio/mpeg")
w.Header().Add("Content-Type", s.mime)

flusher, ok := w.(http.Flusher)
chunked := ok && r.Proto == "HTTP/1.1"
chunked := ok && r.Proto == "HTTP/1.1" && !s.nochunked

if !chunked {
var yearBytes = yearSeconds * (*bitrate / 8) * 1000
w.Header().Add("Content-Length", fmt.Sprint(yearBytes))
size := yearSeconds * (s.bitrate / 8) * 1000
if s.bitrate == 0 {
size = s.samplerate * s.bitdepth * s.channels * yearSeconds
}
w.Header().Add(
"Content-Length",
fmt.Sprint(size),
)
}

if r.Method == http.MethodHead {
Expand All @@ -85,31 +99,117 @@ func (s source) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
endianess := "le"
if s.bige {
endianess = "be"
}
parecCMD := exec.Command(
"parec",
"--device="+s.sink,
"--client-name=blast-rec",
"--rate="+fmt.Sprint(s.samplerate),
"--channels="+fmt.Sprint(s.channels),
"--format="+fmt.Sprintf("s%d%s", s.bitdepth, endianess),
"--raw",
)

parecCMD := exec.Command("parec", "-d", string(s), "-n", "blast-rec")
ffmpegCMD := exec.Command(
"ffmpeg",
"-f", "s16le",
"-ac", "2",
var raw bool
if s.format == "lpcm" || s.format == "wav" {
raw = true
}
if s.format == "lpcm" {
s.format = fmt.Sprintf("s%d%s", s.bitdepth, endianess)
}

ffargs := []string{
"-f", fmt.Sprintf("s%d%s", s.bitdepth, endianess),
"-ac", fmt.Sprint(s.channels),
"-ar", fmt.Sprint(s.samplerate),
"-i", "-",
"-b:a", fmt.Sprintf("%dk", *bitrate),
"-f", "mp3", "-",
)
"-f", s.format, "-",
}
if s.bitrate != 0 {
ffargs = slices.Insert(
ffargs,
len(ffargs)-3,
"-b:a", fmt.Sprintf("%dk", s.bitrate),
)
}
if raw {
ffargs = slices.Insert(
ffargs,
len(ffargs)-1,
"-c:a", fmt.Sprintf("pcm_s%d%s", s.bitdepth, endianess),
)
}
//spew.Dump(strings.Join(ffargs, " "))
ffmpegCMD := exec.Command("ffmpeg", ffargs...)

if *logblast {
fmt.Fprintln(os.Stderr, strings.Join(parecCMD.Args, " "))
parecCMD.Stderr = os.Stderr
fmt.Fprintln(os.Stderr, strings.Join(ffmpegCMD.Args, " "))
ffmpegCMD.Stderr = os.Stderr
}

parecReader, parecWriter := io.Pipe()
parecCMD.Stdout = parecWriter
ffmpegCMD.Stdin = parecReader

ffmpegReader, ffmpegWriter := io.Pipe()
ffmpegCMD.Stdout = ffmpegWriter

parecCMD.Start()
ffmpegCMD.Start()
cleanup := func() {
parecReader.Close()
parecWriter.Close()
ffmpegReader.Close()
ffmpegWriter.Close()
}

var wg sync.WaitGroup
//defer fmt.Println("done")
defer wg.Wait()

err := parecCMD.Start()
if err != nil {
log.Printf("parec failed: %v", err)
return
}
wg.Add(1)
go func() {
err := parecCMD.Wait()
cleanup()
if err != nil && !strings.Contains(err.Error(), "signal") {
log.Println("parec:", err)
w.WriteHeader(http.StatusInternalServerError)
}
wg.Done()
}()

err = ffmpegCMD.Start()
if err != nil {
log.Printf("ffmpeg failed: %v", err)
return
}
wg.Add(1)
go func() {
err := ffmpegCMD.Wait()
cleanup()
if err != nil && !strings.Contains(err.Error(), "signal") {
log.Println("ffmpeg:", err)
w.WriteHeader(http.StatusInternalServerError)
}
wg.Done()
}()
if chunked {
var (
err error
n int
)
buf := make([]byte, (*bitrate/8)*1000**chunk)
buf := make([]byte, (s.bitrate/8)*1000*s.chunk)
if s.bitrate == 0 {
buf = make([]byte, s.samplerate*s.bitdepth*s.channels*s.chunk)
}
for {
n, err = ffmpegReader.Read(buf)
if err != nil {
Expand All @@ -131,4 +231,5 @@ func (s source) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if ffmpegCMD.Process != nil {
ffmpegCMD.Process.Kill()
}
cleanup()
}
8 changes: 4 additions & 4 deletions audio_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import (
"os/exec"
)

func chooseAudioSource(lookup string) (source, error) {
func chooseAudioSource(lookup string) (string, error) {
srcCMD := exec.Command("pactl", "-f", "json", "list", "sources", "short")
srcData, err := srcCMD.Output()
if err != nil {
Expand All @@ -45,18 +45,18 @@ func chooseAudioSource(lookup string) (source, error) {
if len(srcJSON) == 0 {
return "", fmt.Errorf("no audio sources found")
}
// append for on-demand loading of blast sink
srcJSON = append(srcJSON, struct{ Name string }{BLASTMONITOR})
if lookup != "" {
for _, v := range srcJSON {
if v.Name == lookup {
return source(lookup), nil
return lookup, nil
}
}
return "", fmt.Errorf("%s: not found", lookup)
}

fmt.Println("Audio sources")
// append for on-demand loading of blast sink
for i, v := range srcJSON {
fmt.Printf("%d: %s\n", i, v.Name)
}
Expand All @@ -65,7 +65,7 @@ func chooseAudioSource(lookup string) (source, error) {
fmt.Println("Select the audio source:")

selected := selector(srcJSON)
return source(srcJSON[selected].Name), nil
return srcJSON[selected].Name, nil
}

type Sources []struct {
Expand Down
39 changes: 26 additions & 13 deletions av1transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,21 @@ import (
"github.com/huin/goupnp/dcps/av1"
)

func AV1SetAndPlay(loc *url.URL, albumart, stream string) error {
client, err := av1.NewAVTransport1ClientsByURL(loc)
type av1setup struct {
location *url.URL
stream stream
logoURI string
streamURI string
}

func AV1SetAndPlay(av av1setup) error {
client, err := av1.NewAVTransport1ClientsByURL(av.location)
if err != nil {
return err
}

try := func(metadata string) error {
err = client[0].SetAVTransportURI(0, stream, metadata)
err = client[0].SetAVTransportURI(0, av.streamURI, metadata)
if err != nil {
return fmt.Errorf("set uri: %v", err)
}
Expand All @@ -52,8 +59,18 @@ func AV1SetAndPlay(loc *url.URL, albumart, stream string) error {
}
return nil
}

metadata := didlMetadata(albumart, stream)
metadata := fmt.Sprintf(
didlTemplate,
av.logoURI,
av.stream.mime,
av.stream.contentfeat,
av.stream.bitdepth,
av.stream.samplerate,
av.stream.channels,
av.streamURI,
)
metadata = strings.ReplaceAll(metadata, "\n", " ")
metadata = strings.ReplaceAll(metadata, "> <", "><")
err = try(metadata)
if err == nil {
return nil
Expand All @@ -71,13 +88,6 @@ func AV1Stop(loc *url.URL) {
client[0].Stop(0)
}

func didlMetadata(albumart, stream string) string {
out := fmt.Sprintf(didlTemplate, albumart, stream)
out = strings.ReplaceAll(out, "\n", " ")
out = strings.ReplaceAll(out, "> <", "><")
return out
}

const didlTemplate = `<DIDL-Lite
xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"
Expand All @@ -91,6 +101,9 @@ xmlns:pv="http://www.pv.com/pvns/">
<dc:creator>Blast</dc:creator>
<upnp:artist>Blast</upnp:artist>
<upnp:albumArtURI>%s</upnp:albumArtURI>
<res protocolInfo="http-get:*:audio/mpeg:*">%s</res>
<res protocolInfo="http-get:*:%s:%s"
bitsPerSample="%d"
sampleFrequency="%d"
nrAudioChannels="%d">%s</res>
</item>
</DIDL-Lite>`

0 comments on commit 7a53729

Please sign in to comment.