├── .gitignore ├── assets ├── pad-1.mp3 ├── creak-1.mp3 ├── ghost-1.mp3 ├── laugh-1.mp3 └── laugh-2.mp3 ├── Procfile ├── Gemfile ├── config.yml ├── lib ├── ghosty │ ├── settings.rb │ ├── cli.rb │ ├── logger.rb │ ├── scheduler.rb │ └── performer.rb └── ghosty.rb ├── bin └── ghosty ├── Raspi.md ├── Gemfile.lock └── Readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | -------------------------------------------------------------------------------- /assets/pad-1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotwalt/ghosty/HEAD/assets/pad-1.mp3 -------------------------------------------------------------------------------- /assets/creak-1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotwalt/ghosty/HEAD/assets/creak-1.mp3 -------------------------------------------------------------------------------- /assets/ghost-1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotwalt/ghosty/HEAD/assets/ghost-1.mp3 -------------------------------------------------------------------------------- /assets/laugh-1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotwalt/ghosty/HEAD/assets/laugh-1.mp3 -------------------------------------------------------------------------------- /assets/laugh-2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotwalt/ghosty/HEAD/assets/laugh-2.mp3 -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: cd assets; python -m SimpleHTTPServer 3169 2 | daemon: bundle exec bin/ghosty daemon 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gem 'sonos', github: 'gotwalt/sonos' 4 | gem 'thor' 5 | gem 'settingslogic' 6 | gem 'mono_logger' 7 | gem 'foreman' 8 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | port: 3169 2 | valid_hours: 3 | - 20 4 | - 21 5 | - 22 6 | - 23 7 | - 0 8 | - 1 9 | - 2 10 | - 3 11 | minimum_frequency: 45 12 | -------------------------------------------------------------------------------- /lib/ghosty/settings.rb: -------------------------------------------------------------------------------- 1 | require 'settingslogic' 2 | 3 | module Ghosty 4 | class Settings < Settingslogic 5 | source File.expand_path('../../../config.yml', __FILE__) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /bin/ghosty: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | lib = File.expand_path('../../lib', __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'ghosty' 6 | 7 | Ghosty::Cli.start 8 | -------------------------------------------------------------------------------- /lib/ghosty.rb: -------------------------------------------------------------------------------- 1 | require 'ghosty/cli' 2 | require 'ghosty/logger' 3 | require 'ghosty/performer' 4 | require 'ghosty/scheduler' 5 | require 'ghosty/settings' 6 | 7 | module Ghosty 8 | 9 | end 10 | -------------------------------------------------------------------------------- /lib/ghosty/cli.rb: -------------------------------------------------------------------------------- 1 | require 'thor' 2 | require 'irb' 3 | 4 | module Ghosty 5 | class Cli < Thor 6 | 7 | desc 'daemon', 'Runs the service' 8 | def daemon 9 | Ghosty::Scheduler.new.start 10 | end 11 | 12 | desc 'trigger', 'Plays a single sound and then exits' 13 | def trigger 14 | p Ghosty::Scheduler.new.trigger 15 | end 16 | 17 | desc 'cli', 'Launches IRB instance with everything required' 18 | def cli 19 | ARGV.clear 20 | IRB.start 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/ghosty/logger.rb: -------------------------------------------------------------------------------- 1 | require 'mono_logger' 2 | 3 | module Ghosty 4 | class Logger 5 | def self.info(message) 6 | instance.info(message) 7 | end 8 | 9 | def self.warn(message) 10 | instance.warn(message) 11 | end 12 | 13 | def self.debug(message) 14 | instance.debug(message) 15 | end 16 | 17 | def self.error(message) 18 | instance.error(message) 19 | end 20 | 21 | def self.instance 22 | @instance ||= MonoLogger.new(STDOUT) 23 | end 24 | 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /Raspi.md: -------------------------------------------------------------------------------- 1 | # Raspberry Pi Instructions 2 | This project is perfect for a spare Raspberry Pi. Configured properly, you can plug it in to an ethernet port and forget about it. It'll do its thing automatically on boot. Below is an attempt at recalling the steps needed to get it going. 3 | 4 | # Installation 5 | 1. Use raspbian, the Debian build for the board. 6 | 1. Install the following packages: `sudo apt-get install ruby-dev runit`. 7 | 1. Clone and chown the repo: `cd /opt; sudo git clone https://github.com/gotwalt/ghosty.md; sudo chown -R pi /opt/ghosty`. 8 | 1. Disable ruby documentation to make install faster: `sudo echo gem: --no-rdoc --no-ri > /etc/gemrc`. 9 | 1. Update rubygems & bundler: `sudo gem install rubygems-update; sudo update_rubygems; sudo gem install bundler`. 10 | 1. Bundle install: `cd /opt/ghosty; bundle install`. 11 | 1. Generate the upstart configuration: `sudo foreman export runit /etc/sv -a ghosty -l /var/log -u pi` 12 | 1. Set your timezone using `sudo dpkg-reconfigure tzdata`. 13 | 14 | # Runit control 15 | * Start and stop the daemon using `sudo sv ghosty-daemon-1`. 16 | * Start and stop the web server using `sudo sv ghosty-web-1`. 17 | * Monitor daemon output using `tail -f /var/log/daemon-1/current`. 18 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: git://github.com/gotwalt/sonos.git 3 | revision: 023e0c0d68660064f93266d9aabaf777bdd40069 4 | specs: 5 | sonos (0.3.5) 6 | httpclient 7 | nokogiri 8 | savon (~> 2.0) 9 | thor 10 | 11 | GEM 12 | remote: http://rubygems.org/ 13 | specs: 14 | akami (1.2.2) 15 | gyoku (>= 0.4.0) 16 | nokogiri 17 | builder (3.2.2) 18 | dotenv (0.11.1) 19 | dotenv-deployment (~> 0.0.2) 20 | dotenv-deployment (0.0.2) 21 | foreman (0.74.0) 22 | dotenv (~> 0.11.1) 23 | thor (~> 0.19.1) 24 | gyoku (1.1.1) 25 | builder (>= 2.1.2) 26 | httpclient (2.4.0) 27 | httpi (2.2.4) 28 | rack 29 | macaddr (1.7.1) 30 | systemu (~> 2.6.2) 31 | mime-types (1.25.1) 32 | mini_portile (0.6.0) 33 | mono_logger (1.1.0) 34 | nokogiri (1.6.2.1) 35 | mini_portile (= 0.6.0) 36 | nori (2.4.0) 37 | rack (1.5.2) 38 | savon (2.6.0) 39 | akami (~> 1.2.0) 40 | builder (>= 2.1.2) 41 | gyoku (~> 1.1.0) 42 | httpi (~> 2.2.3) 43 | nokogiri (>= 1.4.0) 44 | nori (~> 2.4.0) 45 | uuid (~> 2.3.7) 46 | wasabi (~> 3.3.0) 47 | settingslogic (2.0.9) 48 | systemu (2.6.4) 49 | thor (0.19.1) 50 | uuid (2.3.7) 51 | macaddr (~> 1.0) 52 | wasabi (3.3.0) 53 | httpi (~> 2.0) 54 | mime-types (< 2.0.0) 55 | nokogiri (>= 1.4.0) 56 | 57 | PLATFORMS 58 | ruby 59 | 60 | DEPENDENCIES 61 | foreman 62 | mono_logger 63 | settingslogic 64 | sonos! 65 | thor 66 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Ghosty 2 | Ghosty is a ghost for your Sonos system. It wakes up at night and plays scary noises at low volumes on randomly selected speakers. It covers its tracks, and tries not to play so frequently that it becomes predictable. Really, the only way to know for sure that it's responsible for the sound you're hearing is to watch the Sonos controller as it's happening. 3 | 4 | Ghosty turns any Sonos-powered home into the haunted house of the future. This is meant to be funny, not mean spirited. Please use it in hilarious, good-natured ways. 5 | 6 | ## Requirements 7 | * Ruby 8 | * Python (used to serve audio assets) 9 | 10 | ## Configuration 11 | *./config.yml* is used to configure runtime parameters. 12 | 13 | * **port** sets the HTTP port that the daemon will expect files to be served from. 14 | * **valid_hours** controls the hours during which the ghost is active. 15 | * **minimum_frequency** controls how frequently the ghost will appear. 16 | 17 | ## Running 18 | For normal ghost operations, `foreman start` will start a simple HTTP server to serve files and launch the daemon, which will randomly schedule plays. 19 | 20 | ## CLI Methods 21 | * `ghosty daemon` - starts the scheduling daemon 22 | * `ghosty trigger` - immediately triggers a single play 23 | * `ghosty cli` - launches an IRB session with classes preloaded 24 | 25 | ## Contributing 26 | 27 | 1. Fork it 28 | 2. Create your feature branch (`git checkout -b my-new-feature`) 29 | 3. Commit your changes (`git commit -am 'Add some feature'`) 30 | 4. Push to the branch (`git push origin my-new-feature`) 31 | 5. Create new Pull Request 32 | -------------------------------------------------------------------------------- /lib/ghosty/scheduler.rb: -------------------------------------------------------------------------------- 1 | require 'sonos' 2 | 3 | module Ghosty 4 | # Schedules and manages running performers, maintains state. 5 | class Scheduler 6 | 7 | attr_reader :system 8 | attr_reader :cache 9 | 10 | def initialize 11 | @system = Sonos::System.new 12 | @cache = {} 13 | end 14 | 15 | # Runs the ghost scheduler in a loop 16 | def start 17 | Ghosty::Logger.info 'Started' 18 | 19 | # Handle Control-C signals that may occur while sleeping 20 | trap("SIGINT") do 21 | Ghosty::Logger.info 'Stopped' 22 | return 23 | end 24 | 25 | loop do 26 | frequency = Ghosty::Settings.minimum_frequency 27 | wait_time = (frequency + rand(frequency * 4)) * 60 28 | 29 | Ghosty::Logger.info "Scheduling for #{Time.now + wait_time}" 30 | 31 | sleep(wait_time) 32 | 33 | if Ghosty::Settings.valid_hours.include?(Time.now.hour) 34 | trigger 35 | else 36 | Ghosty::Logger.info 'Skipping - time is out of bounds' 37 | end 38 | end 39 | end 40 | 41 | # Fires off a performer to play a random track 42 | def trigger 43 | begin 44 | results = Ghosty::Performer.new(system, tracks.sample, cache).perform 45 | Ghosty::Logger.info "Played #{File.basename(results[:track])} on #{results[:speaker]} at volume #{results[:volume]} (#{results[:original_volume]})" 46 | rescue Savon::SOAPFault => ex 47 | Ghosty::Logger.error "SOAP Error - #{ex.to_hash.inspect}" 48 | end 49 | 50 | results 51 | end 52 | 53 | # Returns a cached list of track URLs 54 | def tracks 55 | @tracks ||= begin 56 | assets_directory = File.join File.expand_path('../../../', __FILE__), 'assets' 57 | 58 | Dir.glob(File.join(assets_directory, '*.mp3')).map do |file| 59 | "http://#{ip}:#{Ghosty::Settings.port}/#{File.basename(file)}" 60 | end 61 | end 62 | end 63 | 64 | # Returns the IP of the executing machine 65 | def ip 66 | @ip ||= Socket.ip_address_list.detect{|intf| intf.ipv4_private?}.ip_address 67 | end 68 | 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/ghosty/performer.rb: -------------------------------------------------------------------------------- 1 | module Ghosty 2 | class Performer 3 | 4 | attr_reader :system 5 | attr_reader :track 6 | attr_reader :cache 7 | 8 | def initialize(system, track, cache = {}) 9 | @system = system 10 | @cache = cache 11 | @track = track 12 | end 13 | 14 | # Isolates a random speaker and plays the track. 15 | def perform 16 | isolated_from_group(random_speaker) do |independent_speaker| 17 | results = independent_speaker.voiceover!(track, volume) 18 | 19 | results.merge!(speaker: independent_speaker.name, volume: volume, track: track) 20 | end 21 | end 22 | 23 | # Isolates a speaker from any groups it might be in and yields it 24 | def isolated_from_group(speaker) 25 | old_group = system.groups.find{|group| group.slave_speakers.map(&:uid).include?(speaker.uid) } 26 | 27 | if old_group 28 | old_master = speaker.group_master 29 | old_group.disband 30 | end 31 | 32 | if speaker 33 | results = yield speaker 34 | end 35 | 36 | if old_group 37 | old_group.slave_speakers.each do |speaker| 38 | speaker.join old_master 39 | end 40 | end 41 | 42 | results 43 | end 44 | 45 | # Returns a cached randomly selected speaker that isn't currently playing and wasn't used on the last instantiation. 46 | def random_speaker 47 | @random_speaker ||= begin 48 | selected_speaker = system.speakers.select do |speaker| 49 | !speaker.is_playing? && speaker.uid != cache['ghosty.previous_uid'] 50 | end.compact.sample 51 | 52 | cache['ghosty.previous_uid'] = selected_speaker.nil? ? nil : selected_speaker.uid 53 | 54 | selected_speaker 55 | end 56 | end 57 | 58 | # Picks a random volume for the random speaker that's less than its current volume. If the speaker is currently at zero, 59 | # it picks a volume between 0-15. 60 | def volume 61 | @volume ||= begin 62 | current_volume = random_speaker.volume 63 | 64 | if current_volume > 0 65 | (rand(current_volume) * 0.8).to_i 66 | else 67 | rand(15) 68 | end 69 | end 70 | end 71 | end 72 | end 73 | --------------------------------------------------------------------------------