From fe16f5510bea97e5fa2e02038c1c7cba3cb052af Mon Sep 17 00:00:00 2001 From: stunndard Date: Mon, 30 May 2016 20:08:02 +0800 Subject: [PATCH] initial commit --- .gitattributes | 17 ++ .gitignore | 4 + README.md | 118 ++++++++++++++ aac/aac.go | 359 +++++++++++++++++++++++++++++++++++++++++++ aac/aacx.go | 6 + config/config.go | 88 +++++++++++ cuesheet/cuesheet.go | 120 +++++++++++++++ goicy.go | 102 ++++++++++++ goicy.ini | 115 ++++++++++++++ logger/logger.go | 72 +++++++++ metadata/metadata.go | 97 ++++++++++++ network/network.go | 157 +++++++++++++++++++ playlist.txt | 2 + playlist/playlist.go | 66 ++++++++ stream/aac.go | 300 ++++++++++++++++++++++++++++++++++++ util/util.go | 23 +++ 16 files changed, 1646 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 aac/aac.go create mode 100644 aac/aacx.go create mode 100644 config/config.go create mode 100644 cuesheet/cuesheet.go create mode 100644 goicy.go create mode 100644 goicy.ini create mode 100644 logger/logger.go create mode 100644 metadata/metadata.go create mode 100644 network/network.go create mode 100644 playlist.txt create mode 100644 playlist/playlist.go create mode 100644 stream/aac.go create mode 100644 util/util.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..bdb0cab --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# Custom for Visual Studio +*.cs diff=csharp + +# Standard to msysgit +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6edcdf6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +!bin/ +*.exe +.idea/ +tests/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f47ca10 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# goicy - AAC/AACplus/AACplusV2 & MP1/MP2/MP3 Icecast/Shoutcast source client + +![Screenshots](http://i.imgur.com/83kEcKO.png?1) + +## What's the point? +goicy is a small, portable and fast MPEG1/2/2.5 Layer1/2/3 and +AAC/AACplus/AACplusV2 Icecast/Shoutcast source client written in Go. +It is written to be extremely light-weight and easy to use. + + +## How it works? +goicy can work in two modes: `ffmpeg` and `file`. +In ffmpeg mode goicy feeds audio files to ffmpeg which recodes them in realtime +to AAC or MP3 format and sends the output to an Icecast or Shoutcast server. + +In file mode goicy reads and parses AAC or MPEG (MP1, MP2, MP3) files and sends them to +the server without any further processing. + +## What files are supported? +In `ffmpeg` mode: any format of file recognizable by ffmpeg is supported. + +In `file` mode: AAC/AACplus/AACplusV2 and MPEG1/MPEG2/MPEG2.5 LayerI/II/III files +can be streamed to a Icecast or Shoutcast server. All possible bitrates are +fully supported, including CBR and VBR. + +## Tell me more. + - Any audio files readable by ffmpeg are supported. All possible bitrates and their variations, including VBR. + - Pretty precise timing. + - Icecast and Shoutcast servers are fully supported. + - Metadata updating supported. The metadata is read from ID3v1 and ID3v2 tags. + It can also be read from cuesheets (.cue file with the same name as audio file). + + +## What platforms are supported? +Linux and Windows at the moment. + +## What is required? +ffmpeg configured with `--enable-libfdk-aac`. Compile your own, or get the static compiled binaries, +for example here: https://sourceforge.net/projects/ffmpeg-hi/ + +## How do I install goicy? +The `go get` command will automatically fetch all dependencies required, compile the binary and place it in your $GOPATH/bin directory. + + go get github.com/stunndard/goicy + +## How do I configure it? +Read `goicy.ini`. Tune it for your needs. +```INI +[stream] + +; stream type +; must be 'file' or 'ffmpeg' +streamtype = ffmpeg +... + +[ffmpeg] + +; path to the ffmpeg executable +; can be just ffmpeg or ffmpeg.exe if ffmpeg is in PATH +; your ffmpeg should be compiled with fdk_aac support enabled! +ffmpeg = ffmpeg-hi10-heaac + +; sample rate in Hz +; ffmpeg will use its internal resampler +samplerate = 44100 + +; number of channels +; 1 = mono, 2 stereo +channels = 2 + +; AAC stream bitrate +bitrate = 192000 + +; AAC profile +; must be 'lc' for AAC Low Complexity (LC) +; 'he' for AAC SBR (High Efficiency AAC, HEAAC, AAC+, AACplus) +; 'hev2' for AAC SBR + PS (AACplusV2) +aacprofile = lc +``` + +Prepare your static playlist file, like: +``` +/home/goicy/tracks/track1.mp3 +/home/goicy/tracks/track2.flac +/home/goicy/tracks/track3.m4a +/home/goicy/tracks/track4.aac +/home/goicy/tracks/track5.ogg +``` +Mixing different formats in one playlist is perfectly valid in ffmpeg mode! + +In file mode, though, you can only use AAC or MP1/MP2/MP3 files: +``` +/home/goicy/tracks/track1.aac +/home/goicy/tracks/track2.aac +/home/goicy/tracks/track3.aac +/home/goicy/tracks/track4.aac +/home/goicy/tracks/track5.aac +``` + +or +``` +/home/goicy/tracks/track1.mp3 +/home/goicy/tracks/track2.mp3 +/home/goicy/tracks/track3.mp3 +/home/goicy/tracks/track4.mp3 +/home/goicy/tracks/track5.mp3 +``` + +All files should be the same format, bitrate, samplerate and number of channels. +Don't mix different format (MPx/AACx) or different samplerate in one playlist if goicy is set to file +mode. + + +## How do I run it? +goicy goicyini.file, i.e.: + + ./goicy /etc/goicy/rock.ini + diff --git a/aac/aac.go b/aac/aac.go new file mode 100644 index 0000000..7717730 --- /dev/null +++ b/aac/aac.go @@ -0,0 +1,359 @@ +package aac + +import ( + "github.com/stunndard/goicy/logger" + "github.com/stunndard/goicy/util" + "io" + "os" + "strconv" +) + +type Error interface { + error + BadFile() bool // Is that the AAC file error? +} + +type FileError struct { + Err error + msg string +} + +func (e *FileError) Error() string { + return e.msg +} + +func (e *FileError) BadFile() bool { + return true +} + +func isValidFrameHeader(header []byte) (int, bool) { + var ( + syncword uint16 + frame_length int + ) + + sftable := [...]int{ + 96000, 88200, 64000, 48000, + 44100, 32000, 24000, 22050, + 16000, 12000, 11025, 8000, + 7350, 0, 0, 0} + + // check for valid syncowrd + syncword = (uint16(header[0]) << 4) | (uint16(header[1]) >> 4) + if syncword != 0x0FFF { + return 0, false + } + + // get and check the profile + profile := (header[2] & 0x0C0) >> 6 + if profile == 3 { + return 0, false + } + + // get and check the 'sampling_frequency_index': + sfindex := (header[2] & 0x03C) >> 2 + if sftable[sfindex] == 0 { + return 0, false + } + + frame_length = ((int(header[3]) & 0x03) << 11) | (int(header[4]) << 3) | ((int(header[5]) & 0x0E0) >> 5) + if (frame_length < 7) || (frame_length > 5000) { + return 0, false + } + + //(valid frame, len= ', frame_length); + return frame_length, true +} + +func SeekTo1StFrame(f os.File) int { + var ( + buf []byte + aac_header []byte + j int64 + n int + ok bool + ) + + buf = make([]byte, 50000) + f.ReadAt(buf, 0) + + j = -1 + for i := 0; i < len(buf); i++ { + if (buf[i] == 0xFF) && ((buf[i+1] & 0xF0) == 0xF0) { + if len(buf)-i < 10 { + break + } + aac_header = buf[i : i+7] + + if n, ok = isValidFrameHeader(aac_header); ok { + + if i+n+7 >= len(buf) { + break + } + aac_header = buf[i+n : i+n+7] + if _, ok = isValidFrameHeader(aac_header); !ok { + continue + } + + j = int64(i) + f.Seek(j, 0) + break + } + } + } + return int(j) +} + +func GetFrames(f os.File, framesToRead int) ([]byte, error) { + + var framesRead, bytesRead int = 0, 0 + var headers []byte = make([]byte, 7) + var inSync bool = true + var numBytesToRead int = 0 + var buf []byte + var err error + + for framesRead < framesToRead { + + bytesRead, err = f.Read(headers) + if err != nil { + if err != io.EOF { + return nil, err + } + } + if bytesRead < len(headers) { + //input file has ended + break + } + + if _, ok := isValidFrameHeader(headers); !ok { + if inSync { + pos, _ := f.Seek(0, 1) + logger.Log("Bad AAC frame at offset "+strconv.Itoa(int(pos-7))+ + ", resyncing...", logger.LOG_DEBUG) + } + f.Seek(-6, 1) + inSync = false + continue + } + + // from now on, the frame is considered valid + if !inSync { + pos, _ := f.Seek(0, 1) + logger.Log("Resynced at offset "+strconv.Itoa(int(pos-7)), logger.LOG_DEBUG) + } + inSync = true + + // copy frame header to out buffer + buf = append(buf, headers...) + + // extract important fields from aac headers: + FrameLength := ((int(headers[3]) & 0x03) << 11) | (int(headers[4]) << 3) | ((int(headers[5]) & 0x0E0) >> 5) + + if FrameLength > len(headers) { + numBytesToRead = FrameLength - len(headers) + } else { + numBytesToRead = 0 + } + + // read raw frame data + lbuf := make([]byte, numBytesToRead) + bytesRead, err = f.Read(lbuf) + if err != nil { + if err != io.EOF { + return nil, err + } + } + + buf = append(buf, lbuf[0:bytesRead]...) + + if bytesRead < numBytesToRead { + // the input file has ended + break + } + framesRead = framesRead + 1 + } + + return buf, nil +} + +func GetFramesStdin(f io.ReadCloser, framesToRead int) ([]byte, error) { + + var framesRead, bytesRead int = 0, 0 + var headers []byte = make([]byte, 7) + //var inSync bool = true + var numBytesToRead int = 0 + var buf []byte + var err error + + for framesRead < framesToRead { + + bytesRead, err = f.Read(headers) + if err != nil { + if err != io.EOF { + return nil, err + } + } + if bytesRead < len(headers) { + //input file has ended + break + } + + if _, ok := isValidFrameHeader(headers); !ok { + logger.Log("Bad AAC frame encountered", logger.LOG_DEBUG) + } + + // copy frame header to out buffer + buf = append(buf, headers...) + + // extract important fields from aac headers: + FrameLength := ((int(headers[3]) & 0x03) << 11) | (int(headers[4]) << 3) | ((int(headers[5]) & 0x0E0) >> 5) + + if FrameLength > len(headers) { + numBytesToRead = FrameLength - len(headers) + } else { + numBytesToRead = 0 + } + + // read raw frame data + lbuf := make([]byte, numBytesToRead) + bytesRead, err = f.Read(lbuf) + if err != nil { + if err != io.EOF { + return nil, err + } + } + + buf = append(buf, lbuf[0:bytesRead]...) + + if bytesRead < numBytesToRead { + // the input file has ended + break + } + framesRead = framesRead + 1 + } + return buf, nil +} + +// gets information about AAC file +func GetFileInfo(filename string, br *float64, spf, sr, frames, ch *int) error { + + sftable := [...]int{96000, 88200, 64000, 48000, + 44100, 32000, 24000, 22050, + 16000, 12000, 11025, 8000, + 7350, 0, 0, 0} + + if !util.FileExists(filename) { + err := new(FileError) + err.msg = "File doesn't exist" + return err + } + + // open file + f, err := os.Open(filename) + if err != nil { + err := new(FileError) + err.msg = "Cannot open file" + return err + } + + defer f.Close() + + j := SeekTo1StFrame(*f) + if j == -1 { + err := new(FileError) + err.msg = "Couldn't find AAC frame" + return err + } + + logger.Log("First frame found at offset: "+strconv.Itoa(j), logger.LOG_DEBUG) + + // now having opened the input file, read the fixed header of the + // first frame, to get the audio stream's parameters: + fixheader := make([]byte, 4) + + var sfindex byte = 0 + frame := 1 + + if n, err := f.Read(fixheader); (n == len(fixheader)) && (err == nil) { + // check the 'syncword' + if (fixheader[0] != 0x0FF) && ((fixheader[1] & 0x0F0) != 0x0F0) { + err := new(FileError) + err.msg = "Bad \"syncword\" at frame # " + strconv.Itoa(frame) + return err + } + + // get and check the profile + profile := (fixheader[2] & 0x0C0) >> 6 + if profile == 3 { + err := new(FileError) + err.msg = "Bad (reserved) \"profile\":3 at frame # " + strconv.Itoa(frame) + return err + } + + // get and check the 'sampling_frequency_index': + sfindex = (fixheader[2] & 0x3C) >> 2 + if sftable[sfindex] == 0 { + err := new(FileError) + err.msg = "Bad \"sampling_frequency_index\" at frame # " + strconv.Itoa(frame) + return err + } + + // get and check "channel configuration" + *ch = int(((fixheader[2] & 0x01) << 2) | ((fixheader[3] & 0x0C0) >> 6)) + + f.Seek(int64(j), 0) + + headers := make([]byte, 7) + var numBytesToRead int = 0 + + for { + if n, err = f.Read(headers); (n < len(headers)) || (err != nil) { + break + } + protection_absent := headers[1] & 0x01 + var frame_length int = (((int(headers[3]) & 0x03) << 11) | (int(headers[4]) << 3) | ((int(headers[5]) & 0xE0) >> 5)) + + if _, ok := isValidFrameHeader(headers); !ok { + f.Seek(-6, 1) + continue + } else { + frame++ + } + if frame_length > len(headers) { + numBytesToRead = frame_length - len(headers) + } else { + numBytesToRead = 0 + } + if protection_absent == 0 { + f.Seek(2, 1) + if numBytesToRead > 2 { + numBytesToRead -= 2 + } else { + numBytesToRead = 0 + } + } + + //read or skip raw frame data + f.Seek(int64(numBytesToRead), 1) + } + } + finfo, _ := f.Stat() + fsize := finfo.Size() + + *spf = 1024 + *sr = sftable[sfindex] + *frames = frame - 1 + nsamples := 1024 * *frames + playtime := nsamples / *sr + *br = float64(fsize / int64(playtime)) + *br = *br * 8 / 1000 + + logger.Log("frames : "+strconv.Itoa(*frames), logger.LOG_DEBUG) + logger.Log("samplerate: "+strconv.Itoa(*sr)+" Hz", logger.LOG_DEBUG) + logger.Log("channels : "+strconv.Itoa(*ch), logger.LOG_DEBUG) + logger.Log("playtime : "+strconv.Itoa(playtime)+" sec", logger.LOG_DEBUG) + logger.Log("bitrate : "+strconv.Itoa(int(*br))+" kbps (average)", logger.LOG_DEBUG) + + return nil +} diff --git a/aac/aacx.go b/aac/aacx.go new file mode 100644 index 0000000..3a681cc --- /dev/null +++ b/aac/aacx.go @@ -0,0 +1,6 @@ +package aac + +func AacWrite() byte { + // + return 99 +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..798665d --- /dev/null +++ b/config/config.go @@ -0,0 +1,88 @@ +package config + +import ( + "github.com/go-ini/ini" +) + +type Config struct { + StreamType string `ini:"streamtype"` + StreamFormat string `ini:"format"` + StreamBitrate int `ini:"bitrate"` + StreamChannels int `ini:"channels"` + StreamSamplerate int `ini:"samplerate"` + StreamAACProfile string `ini:"aacprofile"` + ServerType string `ini:"server"` + Host string `ini:"host"` + Port int `ini:"port"` + Mount string `ini:"mount"` + ConnAttempts int `ini:"connectionattempts"` + Password string `ini:"password"` + BufferSize int `ini:"buffersize"` + Playlist string `ini:"playlist"` + PlaylistType string `ini:"playlistype"` + NpFile string `ini:"npfile"` + LogFile string `ini:"logfile"` + ScriptFile string `ini:"logfile"` + LogLevel int `ini:"loglevel"` + PlayRandom bool `ini:"playrandom"` + UpdateMetadata bool `ini:"updatemetadata"` + StreamName string `ini:"name"` + StreamDescription string `ini:"description"` + StreamURL string `ini:"url"` + StreamGenre string `ini:"genre"` + StreamPublic bool `ini:"public"` + IsDaemon bool `ini:"daemon"` + FFMPEGPath string +} + +const Version = "0.1" + +var Cfg Config + +func LoadConfig(filename string) error { + + ini, err := ini.Load(filename) + if err != nil { + return err + } + + Cfg.ServerType = ini.Section("server").Key("server").Value() + Cfg.Host = ini.Section("server").Key("host").Value() + Cfg.Port, _ = ini.Section("server").Key("port").Int() + Cfg.Mount = ini.Section("server").Key("mount").Value() + Cfg.ConnAttempts, _ = ini.Section("server").Key("connectionattempts").Int() + Cfg.Password = ini.Section("server").Key("password").Value() + + Cfg.StreamType = ini.Section("stream").Key("streamtype").Value() + Cfg.StreamFormat = ini.Section("stream").Key("format").Value() + Cfg.StreamBitrate, _ = ini.Section("ffmpeg").Key("bitrate").Int() + Cfg.StreamChannels, _ = ini.Section("ffmpeg").Key("channels").Int() + Cfg.StreamSamplerate, _ = ini.Section("ffmpeg").Key("samplerate").Int() + Cfg.StreamAACProfile = ini.Section("ffmpeg").Key("aacprofile").Value() + Cfg.FFMPEGPath = ini.Section("ffmpeg").Key("ffmpeg").Value() + + Cfg.StreamName = ini.Section("stream").Key("name").Value() + Cfg.StreamDescription = ini.Section("stream").Key("description").Value() + Cfg.StreamURL = ini.Section("stream").Key("url").Value() + Cfg.StreamGenre = ini.Section("stream").Key("genre").Value() + Cfg.StreamPublic, _ = ini.Section("stream").Key("public").Bool() + + Cfg.PlaylistType = ini.Section("playlist").Key("playlisttype").Value() + Cfg.Playlist = ini.Section("playlist").Key("playlist").Value() + Cfg.PlayRandom, _ = ini.Section("playlist").Key("playrandom").Bool() + + Cfg.BufferSize, _ = ini.Section("misc").Key("buffersize").Int() + Cfg.BufferSize *= 1000 + Cfg.UpdateMetadata, _ = ini.Section("misc").Key("updatemetadata").Bool() + Cfg.ScriptFile = ini.Section("misc").Key("script").Value() + Cfg.NpFile = ini.Section("misc").Key("npfile").Value() + Cfg.LogFile = ini.Section("misc").Key("logfile").Value() + Cfg.LogLevel, _ = ini.Section("misc").Key("loglevel").Int() + + return nil +} + +func init() { + Cfg.LogLevel = 1 + Cfg.LogFile = "goicy.log" +} diff --git a/cuesheet/cuesheet.go b/cuesheet/cuesheet.go new file mode 100644 index 0000000..0aa7d62 --- /dev/null +++ b/cuesheet/cuesheet.go @@ -0,0 +1,120 @@ +package cuesheet + +import ( + "bufio" + "github.com/stunndard/goicy/logger" + "github.com/stunndard/goicy/metadata" + "io" + "os" + "strconv" + "strings" +) + +type cueEntry struct { + Title string + Artist string + Time uint32 // in milliseconds +} + +var cueEntries []cueEntry +var idx int +var loaded bool + +func get_value(entry, key string) string { + s := string(entry[len(key)+1:]) + if string(s[0]) == "\"" { + s = s[1 : len(s)-1] + } + //fmt.Println(key, "=", s) + return s +} + +func get_time(time string) uint32 { + ttime := time[0 : len(time)-3] //Copy(time, 1, length(time) - 3); + + s := ttime[0 : len(ttime)-3] + z, _ := strconv.Atoi(s) + s = ttime[len(ttime)-2:] + x, _ := strconv.Atoi(s) + //fmt.Println(z) + //fmt.Println(x) + return uint32(z*60000 + x*1000) +} + +func isUpdate(time uint32) bool { + res := false + //fmt.Println("isupdate: ", time) + if idx < len(cueEntries) { + if time >= cueEntries[idx].Time { + idx = idx + 1 + res = true + } + } + return res +} + +func get_tags() (artist, title string) { + if idx > 0 { + artist = cueEntries[idx-1].Artist + title = cueEntries[idx-1].Title + } + return artist, title +} + +func Update(time uint32) { + if !loaded { + return + } + if isUpdate(time) { + md := metadata.FormatMetadata(get_tags()) + go metadata.SendMetadata(md) + } +} + +func Load(cuefile string) bool { + var entry string + + loaded = false + idx = 0 + cueEntries = nil + + f, err := os.Open(cuefile) + if err != nil { + //fmt.Println("error opening file ", err) + return false + } + defer f.Close() + r := bufio.NewReader(f) + for err != io.EOF { + entry, err = r.ReadString(0x0A) // 0x0A separator = newline + entry = strings.Trim(entry, "\r\n ") + for (err != io.EOF) && (entry[0:5] == "TRACK") { + cueEntries = append(cueEntries, cueEntry{}) + entry, err = r.ReadString(0x0A) + entry = strings.Trim(entry, "\r\n ") + for (err != io.EOF) && (entry[0:5] != "TRACK") { + if entry[0:5] == "TITLE" { + cueEntries[idx].Title = get_value(entry, "TITLE") + } + if entry[0:9] == "PERFORMER" { + cueEntries[idx].Artist = get_value(entry, "PERFORMER") + } + if entry[0:8] == "INDEX 01" { + cueEntries[idx].Time = get_time(get_value(entry, "INDEX 01")) + } + if (err != nil) && (err == io.EOF) { + break + } + entry, err = r.ReadString(0x0A) + entry = strings.Trim(entry, "\r\n ") + } + idx = idx + 1 + } + } + loaded = idx > 0 + idx = 0 + if loaded { + logger.Log("Loaded cuesheet: "+cuefile, logger.LOG_INFO) + } + return loaded +} diff --git a/goicy.go b/goicy.go new file mode 100644 index 0000000..e26fd0c --- /dev/null +++ b/goicy.go @@ -0,0 +1,102 @@ +package main + +import ( + "fmt" + "github.com/stunndard/goicy/aac" + "github.com/stunndard/goicy/config" + "github.com/stunndard/goicy/logger" + "github.com/stunndard/goicy/playlist" + "github.com/stunndard/goicy/stream" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + + fmt.Println("=====================================================================") + fmt.Println(" goicy v" + config.Version + " -- A hz reincarnate rewritten in Go") + fmt.Println(" AAC/AACplus/AACplusV2 & MP1/MP2/MP3 Icecast/Shoutcast source client") + fmt.Println(" Copyright (C) 2006-2016 Roman Butusov ") + fmt.Println("=====================================================================") + fmt.Println() + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-sigs + stream.Abort = true + logger.Log("Aborted by user/SIGTERM", logger.LOG_INFO) + }() + + if len(os.Args) < 2 { + fmt.Println("Usage: goicy ") + return + } + inifile := string(os.Args[1]) + + //inifile := "d:\\work\\src\\Go\\src\\goicy\\tests\\hz.ini" + + logger.TermLn("Loading config...", logger.LOG_DEBUG) + err := config.LoadConfig(inifile) + if err != nil { + logger.TermLn(err.Error(), logger.LOG_ERROR) + return + } + logger.File("---------------------------", logger.LOG_INFO) + logger.File("goicy v"+config.Version+" started", logger.LOG_INFO) + logger.Log("Loaded config file: "+inifile, logger.LOG_INFO) + + defer logger.Log("exiting", logger.LOG_INFO) + + if err := playlist.Load(); err != nil { + logger.Log("Cannot load playlist file", logger.LOG_ERROR) + logger.Log(err.Error(), logger.LOG_ERROR) + return + } + + retries := 0 + filename := playlist.Next() + for { + var err error + if config.Cfg.StreamType == "file" { + err = stream.StreamAACFile(filename) + } else { + err = stream.StreamAACFFMPEG(filename) + } + + if err != nil { + // if aborted break immediately + if stream.Abort { + break + } + retries++ + logger.Log("Error streaming: "+err.Error(), logger.LOG_ERROR) + + if retries == config.Cfg.ConnAttempts { + logger.Log("No more retries", logger.LOG_INFO) + break + } + // if that was a file error + if nerr, ok := err.(aac.Error); ok && nerr.BadFile() { + filename = playlist.Next() + } + + logger.Log("Retrying in 10 sec...", logger.LOG_INFO) + for i := 0; i < 10; i++ { + time.Sleep(time.Second * 1) + if stream.Abort { + break + } + } + if stream.Abort { + break + } + continue + } + retries = 0 + filename = playlist.Next() + } +} diff --git a/goicy.ini b/goicy.ini new file mode 100644 index 0000000..14bce87 --- /dev/null +++ b/goicy.ini @@ -0,0 +1,115 @@ +[server] + +; server type +; must be either 'icecast' or 'shoutcast' +server = icecast + +; icecast/shoutcast host and port +host = 127.0.0.1 +port = 8000 + +; icecast mountpoint +; valid only for icecast servers +; has no meaning if server is 'shoutcast' +mount = test + +; icecast/shoutcast source password +password = hackme + +; how many times goicy should try to reconnect to a server before giving up +connectionattempts = 5 + +;------ + +[stream] + +; stream type +; must be 'file' or 'ffmpeg' +streamtype = ffmpeg + +; stream format +; mp3 or aac +format = aac + +; stream name +name = lost in space station + +; stream description +description = get lost in space! + +; stream url +url = http://radio.goicy + +; stream genre +genre = h ego z + +; set public to 1 to publish your stream in icecast/shoutcast +; yp directory, 0 otherwise +public = 0 + +;------ + +[ffmpeg] + +; path to the ffmpeg executable +; can be just ffmpeg or ffmpeg.exe if ffmpeg is in PATH +; ffmpeg should be configured with --enable libfdk_aaac +ffmpeg = ffmpeg-hi10-heaac + +; sample rate in Hz +samplerate = 44100 + +; channels +; 1 = mono, 2 stereo +channels = 2 + +; ffmpeg settings for aac +bitrate = 192000 + +; aac profile +; must be 'lc', 'he', 'hev2' +aacprofile = lc + +;------ + +[playlist] + +; playlist type. must be 'internal' or 'lua' +playlisttype = internal + +; playlist file. +; if playlisttype is 'internal', then playlist is a file +; with track file names, one file on a string +; if playlisttype is 'lua', then playlist is a lua script with some predefined +; functions that are called by goicy +playlist = /some/path/playlist.txt + +; random play order flag, 1 for random, 0 for sequential +; only valid if playlisttype is 'internal' +; has no meaning if playlisttype is 'lua' +playrandom = 0 + +;------- + +[misc] + +; send-ahead buffer size in seconds +buffersize = 3 + +; whether to update stream metadata from ID3 tags. +; 1 to enable, 0 to disable updating. +updatemetadata = 1 + +; script file +script = script.lua + +; nowplay temporary file. used to resume play from the same track +; between subsequent goicy runs. +npfile = np.tmp + +; goicy log file +logfile = /some/path/goicy.log + +; logging verbosity +; set to 0 for normal log, or 1 to be more verbose +loglevel = 1 diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..0cd50b5 --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,72 @@ +package logger + +import ( + "fmt" + "github.com/stunndard/goicy/config" + "github.com/stunndard/goicy/util" + "os" + "strings" + "time" +) + +const ( + LOG_ERROR = iota - 1 + LOG_INFO + LOG_DEBUG +) + +func File(s string, level int) { + var f *os.File + var err error + if level > config.Cfg.LogLevel { + return + } + if util.FileExists(config.Cfg.LogFile) { + f, err = os.OpenFile(config.Cfg.LogFile, os.O_APPEND|os.O_WRONLY, 0666) + if err != nil { + return + } + } else { + f, err = os.OpenFile(config.Cfg.LogFile, os.O_CREATE|os.O_WRONLY, 0666) + if err != nil { + return + } + } + lvl := "" + switch level { + case LOG_ERROR: + lvl = "ERROR" + case LOG_INFO: + lvl = "INFO " + case LOG_DEBUG: + lvl = "DEBUG" + } + date := time.Now().Format("2006-01-02 15:04:05") + n, err := f.WriteString("[" + date + "] " + lvl + " " + s + "\r\n") + if err != nil { + fmt.Println(n) + fmt.Println(err) + } + f.Close() +} + +func Term(s string, level int) { + if level > config.Cfg.LogLevel { + return + } + fmt.Print("\r" + strings.Repeat(" ", 79) + "\r" + s) +} + +func TermLn(s string, level int) { + if level > config.Cfg.LogLevel { + return + } + fmt.Println("\r" + strings.Repeat(" ", 79) + "\r" + s) +} + +// Logs both to the terminal and the log file. +// Puts ln at the end of the logged string +func Log(s string, level int) { + TermLn(s, level) + File(s, level) +} diff --git a/metadata/metadata.go b/metadata/metadata.go new file mode 100644 index 0000000..60ab2d4 --- /dev/null +++ b/metadata/metadata.go @@ -0,0 +1,97 @@ +package metadata + +import ( + "encoding/base64" + "github.com/go-ini/ini" + "github.com/stunndard/goicy/config" + "github.com/stunndard/goicy/logger" + "github.com/stunndard/goicy/network" + "net/url" + "os/exec" + "strings" +) + +func FormatMetadata(artist, title string) string { + md := "" + if artist != "" { + md = artist + " - " + title + } else { + md = title + } + if md == "" { + md = config.Cfg.StreamName + } + return md +} + +func SendMetadata(metadata string) error { + logger.Log("Setting metadata: "+metadata, logger.LOG_INFO) + sock, err := network.Connect(config.Cfg.Host, config.Cfg.Port) + if err != nil { + return err + } + + headers := "" + if config.Cfg.ServerType == "shoutcast" { + headers = "GET /admin.cgi?pass=" + url.QueryEscape("changeme") + + //base64.RawURLEncoding.EncodeToString([]byte("changeme")) + + "&mode=updinfo&song=" + strings.Replace(url.QueryEscape(metadata), "+", "%20", -1) + " HTTP/1.0\r\n" + + "User-Agent: (Mozilla Compatible)\r\n\r\n" + } else { + headers = "GET /admin/metadata?mode=updinfo&mount=/" + config.Cfg.Mount + + "&song=" + strings.Replace(url.QueryEscape(metadata), "+", "%20", -1) + " HTTP/1.0\r\n" + + "User-Agent: goicy/" + config.Version + "\r\n" + //version + crlf + + "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte("source:"+config.Cfg.Password)) + "\r\n" + + "\r\n" + } + if err := network.Send(sock, []byte(headers)); err != nil { + return err + } + return nil +} + +func GetTagsFFMPEG(filename string) error { + cmdName := config.Cfg.FFMPEGPath + cmdArgs := []string{ + "-i", filename, + "-f", "ffmetadata", + "-", + } + + logger.Log("Launching FFMPEG to read tags...", logger.LOG_DEBUG) + cmd := exec.Command(cmdName, cmdArgs...) + + out, err := cmd.Output() + if err != nil { + return err + } + + ini, err := ini.Load(out) + if err != nil { + return err + } + + section, _ := ini.GetSection("") + artist := section.Key("artist").Value() + if artist == "" { + artist = section.Key("ARTIST").Value() + } + + title := section.Key("title").Value() + if title == "" { + title = section.Key("TITLE").Value() + } + + logger.Log("Artist: "+artist, logger.LOG_DEBUG) + logger.Log("Title: "+title, logger.LOG_DEBUG) + + // format metadata + metadata := FormatMetadata(artist, title) + + // send it + if err := SendMetadata(metadata); err != nil { + return err + } + + return nil +} diff --git a/network/network.go b/network/network.go new file mode 100644 index 0000000..0b75726 --- /dev/null +++ b/network/network.go @@ -0,0 +1,157 @@ +package network + +import ( + "encoding/base64" + "errors" + "fmt" + "github.com/stunndard/goicy/config" + "github.com/stunndard/goicy/logger" + "net" + "strconv" + "time" +) + +var Connected bool = false +var csock net.Conn + +func Connect(host string, port int) (net.Conn, error) { + + h := host + ":" + strconv.Itoa(int(port)) + sock, err := net.Dial("tcp", h) + if err != nil { + Connected = false + } + return sock, err +} + +func Send(sock net.Conn, buf []byte) error { + n, err := sock.Write(buf) + if (err != nil) || (n < 1) { + Connected = false + } + return err +} + +func Recv(sock net.Conn) ([]byte, error) { + var buf []byte = make([]byte, 1024) + + n, err := sock.Read(buf) + //fmt.Println(n, err, string(buf), len(buf)) + if err != nil { + logger.Log(err.Error(), logger.LOG_ERROR) + return nil, err + } + + return buf[0:n], err +} + +func Close(sock net.Conn) { + Connected = false + sock.Close() +} + +func ConnectServer(host string, port int, br float64, sr, ch int) (net.Conn, error) { + var sock net.Conn + + if Connected { + return csock, nil + } + + if config.Cfg.ServerType == "shoutcast" { + port++ + } + logger.Log("Connecting to "+config.Cfg.ServerType+" at "+host+":"+strconv.Itoa(port)+"...", logger.LOG_DEBUG) + sock, err := Connect(host, port) + + if err != nil { + Connected = false + return sock, err + } + + //fmt.Println("connected ok") + time.Sleep(time.Second) + + headers := "" + bitrate := 0 + samplerate := 0 + channels := 0 + + if config.Cfg.StreamType == "file" { + bitrate = int(br) + samplerate = sr + channels = ch + } else { + bitrate = config.Cfg.StreamBitrate / 1000 + samplerate = config.Cfg.StreamSamplerate + channels = config.Cfg.StreamChannels + } + + if config.Cfg.ServerType == "shoutcast" { + if err := Send(sock, []byte(config.Cfg.Password+"\r\n")); err != nil { + logger.Log("Error sending password", logger.LOG_ERROR) + Connected = false + return sock, err + } + + time.Sleep(time.Second) + + resp, err := Recv(sock) + if err != nil { + logger.Log("Error receiving ShoutCast response", logger.LOG_ERROR) + Connected = false + return sock, err + } + //fmt.Println(string(resp[0:3])) + if string(resp[0:3]) != "OK2" { + logger.Log("Shoutcast password rejected: "+string(resp), logger.LOG_ERROR) + Connected = false + return sock, err + } + //fmt.Println("password accepted") + headers = "content-type:audio/aacp\r\n" + + "icy-name:" + config.Cfg.StreamName + "\r\n" + + "icy-genre:" + config.Cfg.StreamGenre + "\r\n" + + "icy-url:" + config.Cfg.StreamURL + "\r\n" + + "icy-pub:0\r\n" + + fmt.Sprintf("icy-br:%d\r\n\r\n", bitrate) + } else { + headers = "SOURCE /" + config.Cfg.Mount + " HTTP/1.0\r\n" + + "Content-Type: audio/aacp\r\n" + + "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte("source:"+config.Cfg.Password)) + "\r\n" + + "User-Agent: goicy/" + config.Version + "\r\n" + + "ice-name: " + config.Cfg.StreamName + "\r\n" + + "ice-public: 0\r\n" + + "ice-url: " + config.Cfg.StreamURL + "\r\n" + + "ice-genre: " + config.Cfg.StreamGenre + "\r\n" + + "ice-description: " + config.Cfg.StreamDescription + "\r\n" + + "ice-audio-info: bitrate=" + strconv.Itoa(bitrate) + + ";channels=" + strconv.Itoa(channels) + + ";samplerate=" + strconv.Itoa(samplerate) + "\r\n" + + "\r\n" + } + + if err := Send(sock, []byte(headers)); err != nil { + logger.Log("Error sending headers", logger.LOG_ERROR) + Connected = false + return sock, err + } + + if config.Cfg.ServerType == "icecast" { + time.Sleep(time.Second) + resp, err := Recv(sock) + if err != nil { + Connected = false + return sock, err + } + if string(resp[9:12]) != "200" { + Connected = false + return sock, errors.New("Invalid Icecast response: " + string(resp)) + } + } + + logger.Log("Server connect successful", logger.LOG_INFO) + Connected = true + csock = sock + + return sock, nil +} diff --git a/playlist.txt b/playlist.txt new file mode 100644 index 0000000..34ae24b --- /dev/null +++ b/playlist.txt @@ -0,0 +1,2 @@ +/home/goicy/tracks/track1.aac +/home/goicy/tracks/track2.aac diff --git a/playlist/playlist.go b/playlist/playlist.go new file mode 100644 index 0000000..969e93a --- /dev/null +++ b/playlist/playlist.go @@ -0,0 +1,66 @@ +package playlist + +import ( + "errors" + "github.com/stunndard/goicy/config" + "github.com/stunndard/goicy/util" + "io/ioutil" + "math/rand" + "strings" +) + +var playlist []string +var idx int +var np string + +func Next() string { + //save_idx; + + // get_next_file := pl.Strings[idx]; + if idx > len(playlist)-1 { + idx = 0 + } + np = playlist[idx] + Load() + if idx > len(playlist)-1 { + idx = 0 + } + for (np == playlist[idx]) && (len(playlist) > 1) { + if !config.Cfg.PlayRandom { + idx = idx + 1 + if idx > len(playlist)-1 { + idx = 0 + } + } else { + idx = rand.Intn(len(playlist)) + } + } + return playlist[idx] +} + +func Load() error { + if !util.FileExists(config.Cfg.Playlist) { + return errors.New("Playlist file doesn't exist") + } + + content, err := ioutil.ReadFile(config.Cfg.Playlist) + if err != nil { + //Do something + } + playlist = strings.Split(string(content), "\n") + + i := 0 + for i < len(playlist) { + playlist[i] = strings.Replace(playlist[i], "\r", "", -1) + if !util.FileExists(playlist[i]) { + playlist = append(playlist[:i], playlist[i+1:]...) + continue + } + i += 1 + } + if len(playlist) < 1 { + return errors.New("Error: all files in the playlist do not exist") + } + + return nil +} diff --git a/stream/aac.go b/stream/aac.go new file mode 100644 index 0000000..ee05488 --- /dev/null +++ b/stream/aac.go @@ -0,0 +1,300 @@ +package stream + +import ( + "errors" + "github.com/stunndard/goicy/aac" + "github.com/stunndard/goicy/config" + "github.com/stunndard/goicy/cuesheet" + "github.com/stunndard/goicy/logger" + "github.com/stunndard/goicy/metadata" + "github.com/stunndard/goicy/network" + "github.com/stunndard/goicy/util" + "net" + "os" + "os/exec" + "strconv" + "time" +) + +var totalFramesSent uint64 +var totalTimeBegin time.Time +var Abort bool + +func StreamAACFile(filename string) error { + var ( + br float64 + spf, sr, frames, ch int + sock net.Conn + ) + + cleanUp := func(err error) { + network.Close(sock) + //totalFramesSent = 0 + } + + logger.Log("Checking file: "+filename+"...", logger.LOG_INFO) + + if err := aac.GetFileInfo(filename, &br, &spf, &sr, &frames, &ch); err != nil { + return err + } + + var err error + sock, err = network.ConnectServer(config.Cfg.Host, config.Cfg.Port, br, sr, ch) + if err != nil { + logger.Log("Cannot connect to server", logger.LOG_ERROR) + return err + } + + f, err := os.Open(filename) + if err != nil { + cleanUp(err) + return err + } + + defer f.Close() + + aac.SeekTo1StFrame(*f) + + logger.Log("Streaming file: "+filename+"...", logger.LOG_INFO) + + cuefile := util.Basename(filename) + ".cue" + if config.Cfg.UpdateMetadata { + go metadata.GetTagsFFMPEG(filename) + cuesheet.Load(cuefile) + } + + logger.TermLn("CTRL-C to stop", logger.LOG_INFO) + + framesSent := 0 + + // get number of frames to read in 1 iteration + framesToRead := (sr / spf) + 1 + timeBegin := time.Now() + + for framesSent < frames { + sendBegin := time.Now() + + lbuf, err := aac.GetFrames(*f, framesToRead) + if err != nil { + logger.Log("Error reading data stream", logger.LOG_ERROR) + cleanUp(err) + return err + } + + if err := network.Send(sock, lbuf); err != nil { + cleanUp(err) + logger.Log("Error sending data stream", logger.LOG_ERROR) + return err + } + + framesSent = framesSent + framesToRead + + timeElapsed := int(float64((time.Now().Sub(timeBegin)).Seconds()) * 1000) + timeSent := int(float64(framesSent) * float64(spf) / float64(sr) * 1000) + + bufferSent := 0 + if timeSent > timeElapsed { + bufferSent = timeSent - timeElapsed + } + + if config.Cfg.UpdateMetadata { + cuesheet.Update(uint32(timeElapsed)) + } + + // calculate the send lag + sendLag := int(float64((time.Now().Sub(sendBegin)).Seconds()) * 1000) + + if timeElapsed > 1500 { + logger.Term("Frames: "+strconv.Itoa(framesSent)+"/"+strconv.Itoa(frames)+" Time: "+ + strconv.Itoa(timeElapsed)+"/"+strconv.Itoa(timeSent)+"ms Buffer: "+ + strconv.Itoa(bufferSent)+" Bps: "+strconv.Itoa(len(lbuf)), logger.LOG_INFO) + } + + // regulate sending rate + timePause := 0 + if bufferSent < (config.Cfg.BufferSize - 100) { + timePause = 900 - sendLag + } else { + if bufferSent > config.Cfg.BufferSize { + timePause = 1100 - sendLag + } else { + timePause = 975 - sendLag + } + } + + if Abort { + err := errors.New("aborted by user") + cleanUp(err) + return err + } + + time.Sleep(time.Duration(time.Millisecond) * time.Duration(timePause)) + } + + // pause to clear up the buffer + timeBetweenTracks := int(((float64(frames)*float64(spf))/float64(sr))*1000) - int(float64((time.Now().Sub(timeBegin)).Seconds())*1000) + logger.Log("Pausing for "+strconv.Itoa(timeBetweenTracks)+"ms...", logger.LOG_DEBUG) + time.Sleep(time.Duration(time.Millisecond) * time.Duration(timeBetweenTracks)) + + return nil +} + +func StreamAACFFMPEG(filename string) error { + var ( + sock net.Conn + res error + cmd *exec.Cmd + ) + + cleanUp := func(err error) { + logger.Log("Killing ffmpeg..", logger.LOG_DEBUG) + cmd.Process.Kill() + network.Close(sock) + totalFramesSent = 0 + res = err + } + + var err error + sock, err = network.ConnectServer(config.Cfg.Host, config.Cfg.Port, 0, 0, 0) + if err != nil { + logger.Log("Cannot connect to server", logger.LOG_ERROR) + return err + } + + aacprofile := "" + + if config.Cfg.StreamAACProfile == "lc" { + aacprofile = "aac_low" + } else if config.Cfg.StreamAACProfile == "he" { + aacprofile = "aac_he" + } else { + aacprofile = "aac_he_v2" + } + + cmdArgs := []string{ + "-i", filename, + "-c:a", "libfdk_aac", + "-profile:a", aacprofile, //"aac_low", // + "-b:a", strconv.Itoa(config.Cfg.StreamBitrate), + "-cutoff", "20000", + "-ar", strconv.Itoa(config.Cfg.StreamSamplerate), + //"-ac", strconv.Itoa(config.Cfg.StreamChannels), + "-f", "adts", + "-", + } + + logger.Log("Starting ffmpeg: "+config.Cfg.FFMPEGPath, logger.LOG_DEBUG) + logger.Log("Format : "+aacprofile, logger.LOG_DEBUG) + logger.Log("Bitrate : "+strconv.Itoa(config.Cfg.StreamBitrate), logger.LOG_DEBUG) + logger.Log("Samplerate : "+strconv.Itoa(config.Cfg.StreamSamplerate), logger.LOG_DEBUG) + + cmd = exec.Command(config.Cfg.FFMPEGPath, cmdArgs...) + + f, _ := cmd.StdoutPipe() + + //cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + logger.Log("Error starting ffmpeg", logger.LOG_ERROR) + logger.Log(err.Error(), logger.LOG_ERROR) + return err + } + + logger.Log("Streaming file: "+filename+"...", logger.LOG_INFO) + + cuefile := util.Basename(filename) + ".cue" + if config.Cfg.UpdateMetadata { + go metadata.GetTagsFFMPEG(filename) + cuesheet.Load(cuefile) + } + + logger.TermLn("CTRL-C to stop", logger.LOG_INFO) + + frames := 0 + timeFileBegin := time.Now() + + // get number of frames to read in 1 iteration + // for AAC it's always 1024 samples in one AAC frame + spf := 1024 + sr := config.Cfg.StreamSamplerate + if config.Cfg.StreamAACProfile != "lc" { + sr = sr / 2 + } + framesToRead := (sr / spf) + 1 + + for { + sendBegin := time.Now() + + lbuf, err := aac.GetFramesStdin(f, framesToRead) + if err != nil { + logger.Log("Error reading data stream", logger.LOG_ERROR) + cleanUp(err) + break + } + + if len(lbuf) <= 0 { + logger.Log("STDIN from ffmpeg ended", logger.LOG_DEBUG) + break + } + + if totalFramesSent == 0 { + totalTimeBegin = time.Now() + //stdoutFramesSent = 0 + } + + if err := network.Send(sock, lbuf); err != nil { + logger.Log("Error sending data stream", logger.LOG_DEBUG) + cleanUp(err) + break + } + + totalFramesSent = totalFramesSent + uint64(framesToRead) + frames = frames + framesToRead + + timeElapsed := int(float64((time.Now().Sub(totalTimeBegin)).Seconds()) * 1000) + timeSent := int(float64(totalFramesSent) * float64(spf) / float64(sr) * 1000) + timeFileElapsed := int(float64((time.Now().Sub(timeFileBegin)).Seconds()) * 1000) + + bufferSent := 0 + if timeSent > timeElapsed { + bufferSent = timeSent - timeElapsed + } + + if config.Cfg.UpdateMetadata { + cuesheet.Update(uint32(timeFileElapsed)) + } + + // calculate the send lag + sendLag := int(float64((time.Now().Sub(sendBegin)).Seconds()) * 1000) + + if timeElapsed > 1500 { + logger.Term("Frames: "+strconv.Itoa(frames)+"/"+strconv.Itoa(int(totalFramesSent))+" Time: "+ + strconv.Itoa(int(timeElapsed))+"/"+strconv.Itoa(int(timeSent))+"ms Buffer: "+ + strconv.Itoa(int(bufferSent))+"ms Bps: "+strconv.Itoa(len(lbuf)), logger.LOG_INFO) + } + + // regulate sending rate + timePause := 0 + if bufferSent < (config.Cfg.BufferSize - 100) { + timePause = 900 - sendLag + } else { + if bufferSent > config.Cfg.BufferSize { + timePause = 1100 - sendLag + } else { + timePause = 975 - sendLag + } + } + + if Abort { + err := errors.New("Aborted by user") + cleanUp(err) + break + } + + time.Sleep(time.Duration(time.Millisecond) * time.Duration(timePause)) + } + cmd.Wait() + logger.Log("ffmpeg is dead. hoy!", logger.LOG_DEBUG) + //logger.Log(strconv.Itoa(cmd.ProcessState), logger.LOG_DEBUG) + return res +} diff --git a/util/util.go b/util/util.go new file mode 100644 index 0000000..f3e4191 --- /dev/null +++ b/util/util.go @@ -0,0 +1,23 @@ +package util + +import ( + "os" + "strings" +) + +func FileExists(name string) bool { + finfo, err := os.Stat(name) + if err != nil { + // no such file or dir + return false + } + return !finfo.IsDir() +} + +func Basename(s string) string { + n := strings.LastIndexByte(s, '.') + if n >= 0 { + return s[:n] + } + return s +}