├── .gitignore ├── img ├── screenshot.png ├── glyphicons-halflings.png └── glyphicons-halflings-white.png ├── https ├── run.py ├── server.crt └── server.key ├── src ├── models │ ├── mixer.coffee │ ├── settings.coffee │ ├── microphone.coffee │ ├── playlist.coffee │ └── track.coffee ├── views │ ├── mixer.coffee │ ├── track.coffee │ ├── camera.coffee │ ├── microphone.coffee │ ├── settings.coffee │ └── playlist.coffee ├── webcaster.coffee ├── compat.coffee ├── init.coffee └── node.coffee ├── README.md ├── Makefile ├── LICENSE ├── css └── client.css ├── index.html └── js └── client.js /.gitignore: -------------------------------------------------------------------------------- 1 | .*sw* 2 | -------------------------------------------------------------------------------- /img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalHERMES/webcaster/master/img/screenshot.png -------------------------------------------------------------------------------- /img/glyphicons-halflings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalHERMES/webcaster/master/img/glyphicons-halflings.png -------------------------------------------------------------------------------- /img/glyphicons-halflings-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalHERMES/webcaster/master/img/glyphicons-halflings-white.png -------------------------------------------------------------------------------- /https/run.py: -------------------------------------------------------------------------------- 1 | import BaseHTTPServer, SimpleHTTPServer 2 | import ssl 3 | 4 | httpd = BaseHTTPServer.HTTPServer(('localhost', 8000), SimpleHTTPServer.SimpleHTTPRequestHandler) 5 | httpd.socket = ssl.wrap_socket (httpd.socket, certfile='./https/server.crt', keyfile='./https/server.key', server_side=True) 6 | httpd.serve_forever() 7 | -------------------------------------------------------------------------------- /src/models/mixer.coffee: -------------------------------------------------------------------------------- 1 | class Webcaster.Model.Mixer extends Backbone.Model 2 | getVolume: (position) -> 3 | if position < 0.5 4 | return 2*position 5 | 6 | 1 7 | 8 | getSlider: -> 9 | parseFloat(@get("slider"))/100.00 10 | 11 | getLeftVolume: -> 12 | @getVolume(1.0 - @getSlider()) 13 | 14 | getRightVolume: -> 15 | @getVolume @getSlider() 16 | -------------------------------------------------------------------------------- /src/models/settings.coffee: -------------------------------------------------------------------------------- 1 | class Webcaster.Model.Settings extends Backbone.Model 2 | initialize: (attributes, options) -> 3 | @mixer = options.mixer 4 | 5 | @mixer.on "cue", => 6 | @set passThrough: false 7 | 8 | togglePassThrough: -> 9 | passThrough = @get("passThrough") 10 | if passThrough 11 | @set passThrough: false 12 | else 13 | @mixer.trigger "cue" 14 | @set passThrough: true 15 | -------------------------------------------------------------------------------- /src/views/mixer.coffee: -------------------------------------------------------------------------------- 1 | class Webcaster.View.Mixer extends Backbone.View 2 | render: -> 3 | @$(".slider").slider 4 | stop: => 5 | @$("a.ui-slider-handle").tooltip "hide" 6 | slide: (e, ui) => 7 | @model.set slider: ui.value 8 | @$("a.ui-slider-handle").tooltip "show" 9 | 10 | @$("a.ui-slider-handle").tooltip 11 | title: => @model.get "slider" 12 | trigger: "" 13 | animation: false 14 | placement: "bottom" 15 | 16 | this 17 | -------------------------------------------------------------------------------- /src/webcaster.coffee: -------------------------------------------------------------------------------- 1 | window.Webcaster = Webcaster = 2 | View: {} 3 | Model: {} 4 | Source: {} 5 | 6 | prettifyTime: (time) -> 7 | hours = parseInt time / 3600 8 | time %= 3600 9 | minutes = parseInt time / 60 10 | seconds = parseInt time % 60 11 | 12 | minutes = "0#{minutes}" if minutes < 10 13 | seconds = "0#{seconds}" if seconds < 10 14 | 15 | result = "#{minutes}:#{seconds}" 16 | result = "#{hours}:#{result}" if hours > 0 17 | 18 | result 19 | -------------------------------------------------------------------------------- /src/compat.coffee: -------------------------------------------------------------------------------- 1 | navigator.mediaDevices ||= {} 2 | 3 | navigator.mediaDevices.getUserMedia ||= (constraints) -> 4 | fn = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia 5 | 6 | unless fn? 7 | return Promise.reject new Error("getUserMedia is not implemented in this browser") 8 | 9 | new Promise (resolve, reject) -> 10 | fn.call navigator, constraints, resolve, reject 11 | 12 | navigator.mediaDevices.enumerateDevices ||= -> 13 | Promise.reject new Error("enumerateDevices is not implemented on this browser") 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Webcaster 2 | 3 | Browser-based streaming client for the [webcast.js](https://github.com/webcast/webcast.js) websocket protocol. 4 | 5 | You can try it here: [https://webcast.github.io/webcaster/](https://webcast.github.io/webcaster/) 6 | 7 | You need to run a server that supports the [webcast.js](https://github.com/webcast/webcast.js) protocol. For instance: 8 | ``` 9 | liquidsoap "output.ao(fallible=true,audio_to_stereo(input.harbor('mount',port=8080)))" 10 | ``` 11 | 12 | The client looks like this: 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/models/microphone.coffee: -------------------------------------------------------------------------------- 1 | class Webcaster.Model.Microphone extends Webcaster.Model.Track 2 | initialize: -> 3 | super arguments... 4 | 5 | createSource: (cb) -> 6 | constraints = {video:false} 7 | 8 | if @get("device") 9 | constraints.audio = 10 | exact: @get("device") 11 | else 12 | constraints.audio = true 13 | 14 | @node.createMicrophoneSource constraints, (@source) => 15 | @source.connect @destination 16 | cb?() 17 | 18 | play: -> 19 | @prepare() 20 | 21 | @createSource => 22 | @trigger "playing" 23 | 24 | stop: -> 25 | @source?.disconnect() 26 | 27 | super arguments... 28 | -------------------------------------------------------------------------------- /src/views/track.coffee: -------------------------------------------------------------------------------- 1 | class Webcaster.View.Track extends Backbone.View 2 | initialize: -> 3 | @model.on "change:passThrough", => 4 | if @model.get("passThrough") 5 | @$(".passThrough").addClass("btn-cued").removeClass "btn-info" 6 | else 7 | @$(".passThrough").addClass("btn-info").removeClass "btn-cued" 8 | 9 | @model.on "change:volumeLeft", => 10 | @$(".volume-left").width "#{@model.get("volumeLeft")}%" 11 | 12 | @model.on "change:volumeRight", => 13 | @$(".volume-right").width "#{@model.get("volumeRight")}%" 14 | 15 | onPassThrough: (e) -> 16 | e.preventDefault() 17 | 18 | @model.togglePassThrough() 19 | 20 | onSubmit: (e) -> 21 | e.preventDefault() 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all init run github.io clean 2 | 3 | FILES:=src/compat.coffee src/webcaster.coffee src/node.coffee \ 4 | src/models/track.coffee src/models/microphone.coffee src/models/mixer.coffee src/models/playlist.coffee src/models/settings.coffee \ 5 | src/views/track.coffee src/views/microphone.coffee src/views/camera.coffee src/views/mixer.coffee src/views/playlist.coffee src/views/settings.coffee \ 6 | src/init.coffee 7 | 8 | all: js/client.js 9 | 10 | js/client.js: $(FILES) 11 | cat $^ | coffee --compile --stdio > $@ 12 | 13 | clean: 14 | rm -f js/client.js 15 | 16 | init: 17 | git submodule init 18 | git submodule update 19 | 20 | run: 21 | python -m SimpleHTTPServer 22 | 23 | srun: 24 | python ./https/run.py 25 | -------------------------------------------------------------------------------- /https/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICpDCCAYwCCQCTdfoaKT+Q3zANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDEwls 3 | b2NhbGhvc3QwHhcNMTcwNDExMjExODI2WhcNMTgwNDExMjExODI2WjAUMRIwEAYD 4 | VQQDEwlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDG 5 | vrMxsQMk9eJsvUgL3FxA7svyzNsLlSkrgAKdirT4KfXtzmozJU0bniVciCZsUWRJ 6 | DaYOGoJ+quarC3CverE5VwepDBhYjnv50lgkvFuoon09/xa9TFai7//xhHD6CSqd 7 | E0nE9Tmb6QxwOw8j6LRLzJ9aMlhsNBeOn6SnnltI/eflUhmDQubP3VbBY8RiK8VS 8 | NyRVqu4RoMCUrbwNDITOO6iGz3j4MaGvnoFqPuGH1QMc9DDhHm9R1oaBJfWjcof2 9 | RI+OS98CUWJvV8irlVo1dBrnqOXhoPT3c1FRfREVknNXZE3HTH1yZ1KXTlS3recC 10 | KD+CAuskEF8BJPZslJTdAgMBAAEwDQYJKoZIhvcNAQELBQADggEBADSZabRiC5yN 11 | O6p1NNzpQczyUrcxEjfeAQr/yD7ROjZVU6vxJboVCT/Y/yHZ67ZMdDD/CZP94/ZZ 12 | ZRgI62kluyI1JfKCg52biA/oP6wiEDbjFDuRDFeesoSHkxeDRk1kslJq/ElhARRP 13 | dE9dsLuNjnMNBW5GzgOhMdSykqqcqXzR2sTEkbudxo7E8umib2gdXLFZ8lyPTz9s 14 | /oWPX9o1TnR9Er/dPHLcGA465DL1GCHqUYLJPwzH4BLpJtLMZysn3JqX5HYTZ7m2 15 | mgazDdkPF/kALdcum7kU6CjDWqjNPUTLZ0mVPR1WiYv9DNSOHSqWeRGFizJs8T5v 16 | 3kW1IhpLhEE= 17 | -----END CERTIFICATE----- 18 | -------------------------------------------------------------------------------- /src/views/camera.coffee: -------------------------------------------------------------------------------- 1 | class Webcaster.View.Camera extends Backbone.View 2 | events: 3 | "change .enable-camera": "onEnableCamera" 4 | 5 | initialize: -> 6 | super arguments... 7 | 8 | @model.on "change:camera", => 9 | if @model.get("camera") 10 | navigator.mediaDevices.getUserMedia({audio:false, video:true}).then (stream) => 11 | @$(".camera-preview").get(0).srcObject = stream 12 | @$(".camera-preview").get(0).play() 13 | @model.set "mimeType", @model.get("videoMimeTypes")[0]?.value 14 | @model.set "mimeTypes", @model.get("videoMimeTypes") 15 | else 16 | @$(".camera-preview").get(0).srcObject = null 17 | @model.set "mimeType", @model.get("audioMimeTypes")[0]?.value 18 | @model.set "mimeTypes", @model.get("audioMimeTypes") 19 | 20 | render: -> 21 | navigator.mediaDevices.getUserMedia({audio:false, video:true}).then => 22 | @$(".camera-settings").show() 23 | 24 | this 25 | 26 | onEnableCamera: (e) -> 27 | e.preventDefault() 28 | 29 | @model.set camera: $(e.target).is(":checked") 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2017, Romain Beauxis & Samuel Mimram 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /https/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAxr6zMbEDJPXibL1IC9xcQO7L8szbC5UpK4ACnYq0+Cn17c5q 3 | MyVNG54lXIgmbFFkSQ2mDhqCfqrmqwtwr3qxOVcHqQwYWI57+dJYJLxbqKJ9Pf8W 4 | vUxWou//8YRw+gkqnRNJxPU5m+kMcDsPI+i0S8yfWjJYbDQXjp+kp55bSP3n5VIZ 5 | g0Lmz91WwWPEYivFUjckVaruEaDAlK28DQyEzjuohs94+DGhr56Baj7hh9UDHPQw 6 | 4R5vUdaGgSX1o3KH9kSPjkvfAlFib1fIq5VaNXQa56jl4aD093NRUX0RFZJzV2RN 7 | x0x9cmdSl05Ut63nAig/ggLrJBBfAST2bJSU3QIDAQABAoIBAQCOy09WgZb/xEal 8 | 2C1ekDocUTZpwbQUE1ycK4I1jQZPU16mOOtmygMnt62iWRQ6ORRzxIGtmnBt7/6B 9 | oFubSOzyhMw1MkJ/xgffS7kW09qLRAv2MkQ1SBbrRMLA1WaSz2k1qDkVcReuJHhS 10 | XqyXIUgBOgi4Nnn/bEIMsDkOUaHGw5nQ1vu9AD9tP7BWRjrtw2G9amJ0/BAdZA1p 11 | h8OKIgShedWKhP/xjiLArRplvzW7BBza2Rs4ZGZLWpWDDVXxb6SJ6xjRHSig61He 12 | PVPwuzGiHpgykUeUTR+iX1vfppExQkNEGrgUlG3SkI1FxujQFHBY7TLSK6yuLZZL 13 | 3NyPBBflAoGBAPT3y0t2ESD6cUUvUB+3x9FaRZ0lwoZyolKfQaJL3lota22hy2q4 14 | vhFdJpRKes05x82yFoYfaDRmclIEeTuzO94BGDSaigmvrwHv9PZf3o+4Z18VF4dB 15 | nmdJa6cZn+I+tSEAONpThTVnBRye683GeH5PYFtnDX/WAdcq1Uzx4esbAoGBAM+y 16 | AZXO+fTzhPMN6tFOhinoVt6j/u281zchogb8zrnKkgzHODPQEB5brdj1w4cRYkGg 17 | 6xASuhH4pZlTBOpw1popMasdpyEgfGyXYJdnlQFLap0ZPjErsqfHZzxx4e4a3Wfg 18 | /ymJwEFFM422S3jccb/EDkr2CPhy9FmguGdwl8dnAoGAVfxUo5xBzu2ZrMs6uKdt 19 | wBKLtOl4KnE6gP3dr/YpikW/G7BbEo7eSIIF9K9gA4FwnMcSGOoVTa9YGWnwlAAJ 20 | 15dSw8QrB9Ne/rMXrRNhr9juj/V7WMKzs1WWMAVSlbulIHxPeHMwotoSdUVjR8uO 21 | p+d8zxiRKHoaE0i5rlSJFFkCgYEAgI15FbSKWebwf1A561tjB0Vbn2p6O8QKoYEb 22 | vJ91hOGxDF+ylb9OervcGtHjOBK2clCbrdMpgD4aoZAVFtbIvZqJt5QfAg4NdVks 23 | 3Ams3KRFVP9u0xhs+BQf/fWvFaVjLQzCYn90k/G1Ox1+EUzAyyUag6NNYhoky+UC 24 | vmOeTjkCgYB1L1xqEZ3VldALSh+9tkZzDUsELvXsDzZlHsN+tuygRfTFVvlmaYTD 25 | ii567ZY/9dj3M00mKIOpOtmp1/2oTSLgRrH7EONXRvXOcHNQOXvyJjmAhE+8WgVm 26 | EBLkl2Py9ie+vKb7buXmRduM+E7Zz4VLWw8zASMGTuj8YqCTu896lQ== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /src/models/playlist.coffee: -------------------------------------------------------------------------------- 1 | class Webcaster.Model.Playlist extends Webcaster.Model.Track 2 | initialize: -> 3 | super arguments... 4 | 5 | @mixer.on "change:slider", => @setMixGain() 6 | 7 | prepare: -> 8 | @mixGain = @node.context.createGain() 9 | @mixGain.connect @node.sink 10 | 11 | super arguments... 12 | 13 | stop: -> 14 | @mixGain?.disconnect() 15 | 16 | super arguments... 17 | 18 | sink: -> 19 | @mixGain 20 | 21 | setMixGain: => 22 | return unless @mixGain? 23 | 24 | if @get("side") == "left" 25 | @mixGain.gain.value = @mixer.getLeftVolume() 26 | else 27 | @mixGain.gain.value = @mixer.getRightVolume() 28 | 29 | appendFiles: (newFiles, cb) -> 30 | files = @get "files" 31 | 32 | onDone = _.after newFiles.length, => 33 | @set files: files 34 | cb?() 35 | 36 | addFile = (file) -> 37 | file.readTaglibMetadata (data) => 38 | files.push 39 | file : file 40 | audio : data.audio 41 | metadata : data.metadata 42 | 43 | onDone() 44 | 45 | addFile newFiles[i] for i in [0..newFiles.length-1] 46 | 47 | selectFile: (options = {}) -> 48 | files = @get "files" 49 | index = @get "fileIndex" 50 | 51 | return if files.length == 0 52 | 53 | index += if options.backward then -1 else 1 54 | 55 | index = files.length-1 if index < 0 56 | 57 | if index >= files.length 58 | unless @get("loop") 59 | @set fileIndex: -1 60 | return 61 | 62 | if index < 0 63 | index = files.length-1 64 | else 65 | index = 0 66 | 67 | file = files[index] 68 | @set fileIndex: index 69 | 70 | file 71 | 72 | play: (file) -> 73 | @prepare() 74 | 75 | @setMixGain() 76 | 77 | @node.createFileSource file, this, (@source) => 78 | @source.connect @destination 79 | 80 | if @source.duration? 81 | @set duration: @source.duration() 82 | else 83 | @set duration: parseFloat(file.audio.length) if file.audio?.length? 84 | 85 | @source.play file 86 | @trigger "playing" 87 | 88 | onEnd: -> 89 | @stop() 90 | 91 | @play @selectFile() if @get("playThrough") 92 | -------------------------------------------------------------------------------- /src/views/microphone.coffee: -------------------------------------------------------------------------------- 1 | class Webcaster.View.Microphone extends Webcaster.View.Track 2 | events: 3 | "click .record-audio" : "onRecord" 4 | "click .passThrough" : "onPassThrough" 5 | "change .audio-device" : "onAudioDevice" 6 | "submit" : "onSubmit" 7 | 8 | initialize: -> 9 | super arguments... 10 | 11 | @model.on "playing", => 12 | @$(".play-control").removeAttr "disabled" 13 | @$(".record-audio").addClass "btn-recording" 14 | @$(".volume-left").width "0%" 15 | @$(".volume-right").width "0%" 16 | 17 | @model.on "stopped", => 18 | @$(".record-audio").removeClass "btn-recording" 19 | @$(".volume-left").width "0%" 20 | @$(".volume-right").width "0%" 21 | 22 | render: -> 23 | @$(".microphone-slider").slider 24 | orientation: "vertical" 25 | min: 0 26 | max: 150 27 | value: 100 28 | stop: => 29 | @$("a.ui-slider-handle").tooltip "hide" 30 | slide: (e, ui) => 31 | @model.set trackGain: ui.value 32 | @$("a.ui-slider-handle").tooltip "show" 33 | 34 | @$("a.ui-slider-handle").tooltip 35 | title: => @model.get "trackGain" 36 | trigger: "" 37 | animation: false 38 | placement: "left" 39 | 40 | navigator.mediaDevices.getUserMedia({audio:true, video:false}).then => 41 | navigator.mediaDevices.enumerateDevices().then (devices) => 42 | devices = _.filter devices, ({kind, deviceId}) -> 43 | kind == "audioinput" 44 | 45 | return if _.isEmpty devices 46 | 47 | $select = @$(".microphone-entry select") 48 | 49 | _.each devices, ({label,deviceId}) -> 50 | $select.append "" 51 | 52 | $select.find("option:eq(0)").prop "selected", true 53 | 54 | @model.set "device", $select.val() 55 | 56 | $select.select -> 57 | @model.set "device", $select.val() 58 | 59 | @$(".microphone-entry").show() 60 | 61 | this 62 | 63 | onAudioDevice:(e) -> 64 | @model.set device: $(e.target).val() 65 | 66 | onRecord: (e) -> 67 | e.preventDefault() 68 | 69 | if @model.isPlaying() 70 | return @model.stop() 71 | 72 | @$(".play-control").attr disabled: "disabled" 73 | @model.play() 74 | -------------------------------------------------------------------------------- /css/client.css: -------------------------------------------------------------------------------- 1 | div.volume-slider { 2 | float: left; 3 | height: 165px; 4 | margin-right: 5px; 5 | } 6 | 7 | div.progress-seek { 8 | position: absolute; 9 | display: inline-block; 10 | height: 20px; 11 | z-index: 1000; 12 | } 13 | 14 | div.progress-volume { 15 | margin-left: 25px; 16 | } 17 | 18 | span.track-position-text { 19 | position: absolute; 20 | text-align: center; 21 | z-index: 999; 22 | } 23 | 24 | div.track-position { 25 | position: absolute; 26 | height: 20px !important; 27 | width: 0px; 28 | } 29 | 30 | div.progress-left { 31 | height: 10px; 32 | margin-bottom: 2px; 33 | margin-left: 25px; 34 | } 35 | 36 | div.progress-right { 37 | height: 10px; 38 | margin-bottom: 10px; 39 | margin-left: 25px; 40 | } 41 | 42 | div.volume-bar { 43 | height: 10px; 44 | } 45 | 46 | .playlist-label { 47 | margin-bottom: 5px; 48 | text-align: center; 49 | } 50 | 51 | @keyframes blink { 52 | 0% { opacity: 1.0; } 53 | 50% { opacity: 0.0; } 54 | 100% { opacity: 1.0; } 55 | } 56 | 57 | @-webkit-keyframes blink { 58 | 0% { opacity: 1.0; } 59 | 50% { opacity: 0.0; } 60 | 100% { opacity: 1.0; } 61 | } 62 | 63 | @-moz-keyframes blink { 64 | 0% { opacity: 1.0; } 65 | 50% { opacity: 0.0; } 66 | 100% { opacity: 1.0; } 67 | } 68 | 69 | .blink { 70 | -webkit-animation: blink 1.1s linear infinite; 71 | -moz-animation: blink 1.1s linear infinite; 72 | animation: blink 1.1s linear infinite; 73 | } 74 | 75 | @keyframes btn-cued { 76 | 0% { background: rgb(250,167,50); } 77 | 50% { background: rgb(238,238,238); } 78 | 100% { background: rgb(250,167,50); } 79 | } 80 | 81 | @-webkit-keyframes btn-cued { 82 | 0% { background: rgb(250,167,50); } 83 | 50% { background: rgb(238,238,238); } 84 | 100% { background: rgb(250,167,50); } 85 | } 86 | 87 | @-moz-keyframes btn-cued { 88 | 0% { background: rgb(250,167,50); } 89 | 50% { background: rgb(238,238,238); } 90 | 100% { background: rgb(250,167,50); } 91 | } 92 | 93 | .btn-cued { 94 | color: "black"; 95 | background-image: none; 96 | -webkit-animation: btn-cued 1.1s linear infinite; 97 | -moz-animation: btn-cued 1.1s linear infinite; 98 | animation: btn-cued 1.1s linear infinite; 99 | } 100 | 101 | div.microphone { 102 | margin-top: 25px; 103 | } 104 | 105 | @keyframes btn-recording { 106 | 0% { background: rgb(218, 79, 73) } 107 | 50% { background: rgb(238,238,238); } 108 | 100% { background: rgb(218, 79, 73) } 109 | } 110 | 111 | @-webkit-keyframes btn-recording { 112 | 0% { background: rgb(218, 79, 73) } 113 | 50% { background: rgb(238,238,238); } 114 | 100% { background: rgb(218, 79, 73) } 115 | } 116 | 117 | @-moz-keyframes btn-recording { 118 | 0% { background: rgb(218, 79, 73) } 119 | 50% { background: rgb(238,238,238); } 120 | 100% { background: rgb(218, 79, 73) } 121 | } 122 | 123 | .btn-recording { 124 | background-image: none; 125 | -webkit-animation: btn-recording 1.1s linear infinite; 126 | -moz-animation: btn-recording 1.1s linear infinite; 127 | animation: btn-recording 1.1s linear infinite; 128 | } 129 | 130 | .microphone-label { 131 | margin-bottom: 5px; 132 | text-align:center; 133 | } 134 | 135 | div.microphone-slider { 136 | position: absolute; 137 | height: 127px; 138 | } 139 | 140 | .mixer-label { 141 | text-align: center; 142 | } 143 | -------------------------------------------------------------------------------- /src/init.coffee: -------------------------------------------------------------------------------- 1 | $ -> 2 | Webcaster.mixer = new Webcaster.Model.Mixer 3 | slider: 0 4 | 5 | enabledMimeTypes = (types) => 6 | _.filter types, ({value}) => 7 | MediaRecorder.isTypeSupported value 8 | 9 | audioMimeTypes = enabledMimeTypes [ 10 | { name: "Opus audio", value: "audio/webm;codecs=opus"} 11 | ] 12 | videoMimeTypes = enabledMimeTypes [ 13 | { name: "Opus audio/h264 video", value: "video/webm;codecs=h264,opus"}, 14 | { name: "Opus audio/vp9 video", value: "video/webm;codecs=vp9,opus"}, 15 | { name: "Opus audio/vp8 video", value: "video/webm;codecs=vp8,opus"} 16 | ] 17 | 18 | Webcaster.settings = new Webcaster.Model.Settings({ 19 | url: "ws://source:hackme@localhost:8080/mount" 20 | audioBitrate: 128 21 | audioBitrates: [ 8, 16, 24, 32, 40, 48, 56, 22 | 64, 80, 96, 112, 128, 144, 23 | 160, 192, 224, 256, 320 ] 24 | videoBitrate: 2.5 25 | videoBitrates: [ 2.5, 3.5, 5, 7, 10] 26 | samplerate: 44100 27 | samplerates: [ 8000, 11025, 12000, 16000, 28 | 22050, 24000, 32000, 44100, 48000 ] 29 | channels: 2 30 | mimeTypes: audioMimeTypes 31 | audioMimeTypes: audioMimeTypes 32 | videoMimeTypes: videoMimeTypes 33 | mimeType: audioMimeTypes[0]?.value 34 | passThrough: false 35 | camera: false 36 | streaming: false 37 | playing: 0 38 | }, { 39 | mixer: Webcaster.mixer 40 | }) 41 | 42 | Webcaster.node = new Webcaster.Node 43 | model: Webcaster.settings 44 | 45 | _.extend Webcaster, 46 | views: 47 | settings : new Webcaster.View.Settings 48 | model : Webcaster.settings 49 | node : Webcaster.node 50 | el : $("div.settings") 51 | 52 | mixer: new Webcaster.View.Mixer 53 | model : Webcaster.mixer 54 | el : $("div.mixer") 55 | 56 | microphone: new Webcaster.View.Microphone 57 | model: new Webcaster.Model.Microphone({ 58 | trackGain : 100 59 | passThrough : false 60 | }, { 61 | mixer: Webcaster.mixer 62 | node: Webcaster.node 63 | }) 64 | el: $("div.microphone") 65 | 66 | camera: new Webcaster.View.Camera 67 | model: Webcaster.settings 68 | el: $("div.camera") 69 | 70 | playlistLeft : new Webcaster.View.Playlist 71 | model : new Webcaster.Model.Playlist({ 72 | side : "left" 73 | files : [] 74 | fileIndex : -1 75 | volumeLeft : 0 76 | volumeRight : 0 77 | trackGain : 100 78 | passThrough : false 79 | playThrough : true 80 | position : 0.0 81 | loop : false 82 | }, { 83 | mixer : Webcaster.mixer 84 | node : Webcaster.node 85 | }) 86 | el : $("div.playlist-left") 87 | 88 | playlistRight : new Webcaster.View.Playlist 89 | model : new Webcaster.Model.Playlist({ 90 | side : "right" 91 | files : [] 92 | fileIndex : -1 93 | volumeLeft : 0 94 | volumeRight : 0 95 | trackGain : 100 96 | passThrough : false 97 | playThrough : true 98 | position : 0.0 99 | loop : false 100 | }, { 101 | mixer : Webcaster.mixer 102 | node : Webcaster.node 103 | }) 104 | el : $("div.playlist-right") 105 | 106 | 107 | _.invoke Webcaster.views, "render" 108 | -------------------------------------------------------------------------------- /src/models/track.coffee: -------------------------------------------------------------------------------- 1 | class Webcaster.Model.Track extends Backbone.Model 2 | initialize: (attributes, options) -> 3 | @node = options.node 4 | @mixer = options.mixer 5 | 6 | @mixer.on "cue", => 7 | @set passThrough: false 8 | 9 | @on "change:trackGain", @setTrackGain 10 | @on "ended", @stop 11 | 12 | togglePassThrough: -> 13 | passThrough = @get("passThrough") 14 | if passThrough 15 | @set passThrough: false 16 | else 17 | @mixer.trigger "cue" 18 | @set passThrough: true 19 | 20 | isPlaying: -> 21 | @source? 22 | 23 | createControlsNode: -> 24 | bufferSize = 4096 25 | bufferLength = parseFloat(bufferSize)/parseFloat(@node.context.sampleRate) 26 | 27 | bufferLog = Math.log parseFloat(bufferSize) 28 | log10 = 2.0 * Math.log(10) 29 | 30 | source = @node.context.createScriptProcessor bufferSize, 2, 2 31 | 32 | source.onaudioprocess = (buf) => 33 | ret = {} 34 | 35 | if @source?.position? 36 | ret["position"] = @source.position() 37 | else 38 | if @source? 39 | ret["position"] = parseFloat(@get("position"))+bufferLength 40 | 41 | for channel in [0..buf.inputBuffer.numberOfChannels-1] 42 | channelData = buf.inputBuffer.getChannelData channel 43 | 44 | rms = 0.0 45 | for i in [0..channelData.length-1] 46 | rms += Math.pow channelData[i], 2 47 | volume = 100*Math.exp((Math.log(rms)-bufferLog)/log10) 48 | 49 | if channel == 0 50 | ret["volumeLeft"] = volume 51 | else 52 | ret["volumeRight"] = volume 53 | 54 | @set ret 55 | 56 | buf.outputBuffer.getChannelData(channel).set channelData 57 | 58 | source 59 | 60 | createPassThrough: -> 61 | source = @node.context.createScriptProcessor 256, 2, 2 62 | 63 | source.onaudioprocess = (buf) => 64 | channelData = buf.inputBuffer.getChannelData channel 65 | 66 | for channel in [0..buf.inputBuffer.numberOfChannels-1] 67 | if @get("passThrough") 68 | buf.outputBuffer.getChannelData(channel).set channelData 69 | else 70 | buf.outputBuffer.getChannelData(channel).set (new Float32Array channelData.length) 71 | 72 | source 73 | 74 | setTrackGain: => 75 | return unless @trackGain? 76 | @trackGain.gain.value = parseFloat(@get("trackGain"))/100.0 77 | 78 | sink: -> 79 | @node.sink 80 | 81 | prepare: -> 82 | @node.registerSource() 83 | 84 | @controlsNode = @createControlsNode() 85 | @controlsNode.connect @sink() 86 | 87 | @trackGain = @node.context.createGain() 88 | @trackGain.connect @controlsNode 89 | @setTrackGain() 90 | 91 | @destination = @trackGain 92 | 93 | @passThrough = @createPassThrough() 94 | @passThrough.connect @node.context.destination 95 | @destination.connect @passThrough 96 | 97 | @node.context.resume() 98 | 99 | togglePause: -> 100 | return unless @source?.pause? 101 | 102 | if @source?.paused?() 103 | @source.play() 104 | @trigger "playing" 105 | else 106 | @source.pause() 107 | @trigger "paused" 108 | 109 | stop: -> 110 | @source?.stop?() 111 | @source?.disconnect() 112 | @trackGain?.disconnect() 113 | @controlsNode?.disconnect() 114 | @passThrough?.disconnect() 115 | 116 | @source = @trackGain = @controlsNode = @passThrough = null 117 | 118 | @set position: 0.0 119 | @node.unregisterSource() 120 | 121 | @trigger "stopped" 122 | 123 | seek: (percent) -> 124 | return unless position = @source?.seek?(percent) 125 | 126 | @set position: position 127 | 128 | sendMetadata: (file) -> 129 | @node.sendMetadata file.metadata 130 | -------------------------------------------------------------------------------- /src/node.coffee: -------------------------------------------------------------------------------- 1 | class Webcaster.Node 2 | _.extend @prototype, Backbone.Events 3 | 4 | constructor: ({@model}) -> 5 | setContext = => 6 | sampleRate = @model.get("samplerate") 7 | channels = @model.get("channels") 8 | 9 | @context = new AudioContext( 10 | sampleRate: sampleRate 11 | ) 12 | 13 | @sink = @context.createScriptProcessor 256, 2, 2 14 | @sink.onaudioprocess = (buf) => 15 | channelData = buf.inputBuffer.getChannelData channel 16 | 17 | for channel in [0..buf.inputBuffer.numberOfChannels-1] 18 | buf.outputBuffer.getChannelData(channel).set channelData 19 | 20 | @playThrough = @context.createScriptProcessor 256, 2, 2 21 | 22 | @playThrough.onaudioprocess = (buf) => 23 | channelData = buf.inputBuffer.getChannelData channel 24 | 25 | for channel in [0..buf.inputBuffer.numberOfChannels-1] 26 | if @model.get("passThrough") 27 | buf.outputBuffer.getChannelData(channel).set channelData 28 | else 29 | buf.outputBuffer.getChannelData(channel).set (new Float32Array channelData.length) 30 | 31 | @sink.connect @playThrough 32 | @playThrough.connect @context.destination 33 | 34 | @streamNode = @context.createMediaStreamDestination() 35 | @streamNode.channelCount = channels 36 | 37 | @sink.connect @streamNode 38 | 39 | setContext() 40 | 41 | @model.on "change:samplerate", setContext 42 | @model.on "change:channels", setContext 43 | 44 | registerSource: -> 45 | @model.set "playing", @model.get("playing") + 1 46 | 47 | unregisterSource: -> 48 | @model.set "playing", Math.max(0, @model.get("playing") - 1) 49 | 50 | startStream: => 51 | @model.set "streaming", true 52 | 53 | @context.resume() 54 | 55 | mimeType = @model.get("mimeType") 56 | audioBitrate = Number(@model.get("audioBitrate"))*1000; 57 | videoBitrate = Number(@model.get("videoBitrate"))*1000000; 58 | url = @model.get("url") 59 | 60 | if @model.get("camera") 61 | @streamNode.stream.addTrack $(".camera-preview").get(0).captureStream().getTracks()[0] 62 | 63 | @mediaRecorder = new MediaRecorder(@streamNode.stream, 64 | mimeType: mimeType, 65 | audioBitsPerSecond: audioBitrate 66 | videoBitsPerSecond: videoBitrate 67 | ); 68 | 69 | @socket = new Webcast.Socket( 70 | mediaRecorder: @mediaRecorder, 71 | url: url 72 | ) 73 | 74 | @mediaRecorder.start(1000) 75 | 76 | stopStream: => 77 | @mediaRecorder?.stop() 78 | @model.set "streaming", false 79 | 80 | createAudioSource: ({file, audio}, model, cb) -> 81 | el = new Audio URL.createObjectURL(file) 82 | el.controls = false 83 | el.autoplay = false 84 | el.loop = false 85 | 86 | el.addEventListener "ended", => 87 | model.onEnd() 88 | 89 | source = null 90 | 91 | el.addEventListener "canplay", => 92 | return if source? 93 | 94 | source = @context.createMediaElementSource el 95 | 96 | source.play = -> 97 | el.play() 98 | 99 | source.position = -> 100 | el.currentTime 101 | 102 | source.duration = -> 103 | el.duration 104 | 105 | source.paused = -> 106 | el.paused 107 | 108 | source.stop = -> 109 | el.pause() 110 | el.remove() 111 | 112 | source.pause = -> 113 | el.pause() 114 | 115 | source.seek = (percent) -> 116 | time = percent*parseFloat(audio.length) 117 | 118 | el.currentTime = time 119 | time 120 | 121 | cb source 122 | 123 | createFileSource: (file, model, cb) -> 124 | @source?.disconnect() 125 | 126 | @createAudioSource file, model, cb 127 | 128 | createMicrophoneSource: (constraints, cb) -> 129 | navigator.mediaDevices.getUserMedia(constraints).then (stream) => 130 | source = @context.createMediaStreamSource stream 131 | 132 | source.stop = -> 133 | stream.getAudioTracks()?[0].stop() 134 | 135 | cb source 136 | 137 | sendMetadata: (data) -> 138 | @socket?.sendMetadata data 139 | -------------------------------------------------------------------------------- /src/views/settings.coffee: -------------------------------------------------------------------------------- 1 | class Webcaster.View.Settings extends Backbone.View 2 | events: 3 | "change .url" : "onUrl" 4 | "change input.encoder" : "onEncoder" 5 | "change input.channels" : "onChannels" 6 | "change .mimeType" : "onMimeType" 7 | "change .samplerate" : "onSamplerate" 8 | "change .audio-bitrate" : "onAudioBitrate" 9 | "change .video-bitrate" : "onVideoBitrate" 10 | "change .asynchronous" : "onAsynchronous" 11 | "click .passThrough" : "onPassThrough" 12 | "click .start-stream" : "onStart" 13 | "click .stop-stream" : "onStop" 14 | "click .update-metadata" : "onMetadataUpdate" 15 | "submit" : "onSubmit" 16 | 17 | initialize: ({@node}) -> 18 | @model.on "change:mimeTypes", => @setFormats() 19 | 20 | @model.on "change:passThrough", => 21 | if @model.get("passThrough") 22 | @$(".passThrough").addClass("btn-cued").removeClass "btn-info" 23 | else 24 | @$(".passThrough").addClass("btn-info").removeClass "btn-cued" 25 | 26 | @model.on "change:playing", => 27 | if @model.get("playing") > 0 28 | @setPlaying() 29 | else 30 | @setNotPlaying() 31 | 32 | @model.on "change:streaming", => 33 | if @model.get("streaming") 34 | @setStreaming() 35 | else 36 | @setNotStreaming() 37 | 38 | @model.on "change:camera", => 39 | if @model.get("camera") 40 | @$(".video-settings").show() 41 | else 42 | @$(".video-settings").hide() 43 | 44 | setPlaying: -> 45 | @$(".samplerate, .channels").attr disabled: "disabled" 46 | 47 | setNotPlaying: -> 48 | @$(".samplerate, .channels").removeAttr "disabled" 49 | 50 | setStreaming: -> 51 | @setPlaying() 52 | 53 | @$(".stop-stream").show() 54 | @$(".start-stream").hide() 55 | @$(".mimeType, .audio-bitrate, .video-bitrate, .url").attr disabled: "disabled" 56 | @$(".manual-metadata, .update-metadata").removeAttr "disabled" 57 | 58 | setNotStreaming: -> 59 | unless @model.get("playing") 60 | @setNotPlaying() 61 | 62 | @$(".stop-stream").hide() 63 | @$(".start-stream").show() 64 | @$(".mimeType, .audio-bitrate, .video-bitrate, .url").removeAttr "disabled" 65 | @$(".manual-metadata, .update-metadata").attr disabled: "disabled" 66 | 67 | setFormats: -> 68 | mimeType = @model.get "mimeType" 69 | @$(".mimeType").empty() 70 | _.each @model.get("mimeTypes"), ({name, value}) => 71 | selected = if mimeType == value then "selected" else "" 72 | $(""). 73 | appendTo @$(".mimeType") 74 | 75 | render: -> 76 | @setFormats() 77 | 78 | samplerate = @model.get "samplerate" 79 | @$(".samplerate").empty() 80 | _.each @model.get("samplerates"), (rate) => 81 | selected = if samplerate == rate then "selected" else "" 82 | $(""). 83 | appendTo @$(".samplerate") 84 | 85 | audioBitrate = @model.get "audioBitrate" 86 | @$(".audio-bitrate").empty() 87 | _.each @model.get("audioBitrates"), (rate) => 88 | selected = if audioBitrate == rate then "selected" else "" 89 | $(""). 90 | appendTo @$(".audio-bitrate") 91 | 92 | videoBitrate = @model.get "videoBitrate" 93 | @$(".video-bitrate").empty() 94 | _.each @model.get("videoBitrates"), (rate) => 95 | selected = if videoBitrate == rate then "selected" else "" 96 | $(""). 97 | appendTo @$(".video-bitrate") 98 | 99 | this 100 | 101 | onUrl: -> 102 | @model.set url: @$(".url").val() 103 | 104 | onEncoder: (e) -> 105 | @model.set encoder: $(e.target).val() 106 | 107 | onChannels: (e) -> 108 | @model.set channels: parseInt($(e.target).val()) 109 | 110 | onMimeType: (e) -> 111 | @model.set mimeType: $(e.target).val() 112 | 113 | onSamplerate: (e) -> 114 | @model.set samplerate: parseInt($(e.target).val()) 115 | 116 | onAudioBitrate: (e) -> 117 | @model.set audioBitrate: parseInt($(e.target).val()) 118 | 119 | onVideoBitrate: (e) -> 120 | @model.set videoBitrate: parseInt($(e.target).val()) 121 | 122 | onAsynchronous: (e) -> 123 | @model.set asynchronous: $(e.target).is(":checked") 124 | 125 | onPassThrough: (e) -> 126 | e.preventDefault() 127 | 128 | @model.togglePassThrough() 129 | 130 | onStart: (e) -> 131 | e.preventDefault() 132 | 133 | @node.startStream() 134 | 135 | onStop: (e) -> 136 | e.preventDefault() 137 | 138 | @node.stopStream() 139 | 140 | onMetadataUpdate: (e) -> 141 | e.preventDefault() 142 | 143 | title = @$(".manual-metadata.artist").val() 144 | artist = @$(".manual-metadata.title").val() 145 | 146 | return unless artist != "" || title != "" 147 | 148 | @node.sendMetadata 149 | artist: artist 150 | title: title 151 | 152 | @$(".metadata-updated").show 400, => 153 | cb = => 154 | @$(".metadata-updated").hide 400 155 | 156 | setTimeout cb, 2000 157 | 158 | onSubmit: (e) -> 159 | e.preventDefault() 160 | -------------------------------------------------------------------------------- /src/views/playlist.coffee: -------------------------------------------------------------------------------- 1 | class Webcaster.View.Playlist extends Webcaster.View.Track 2 | events: 3 | "click .play-audio" : "onPlay" 4 | "click .pause-audio" : "onPause" 5 | "click .previous" : "onPrevious" 6 | "click .next" : "onNext" 7 | "click .stop" : "onStop" 8 | "click .progress-seek" : "onSeek" 9 | "click .passThrough" : "onPassThrough" 10 | "change .files" : "onFiles" 11 | "change .playThrough" : "onPlayThrough" 12 | "change .loop" : "onLoop" 13 | "submit" : "onSubmit" 14 | 15 | initialize: -> 16 | super arguments... 17 | 18 | @model.on "change:fileIndex", => 19 | @$(".track-row").removeClass "success" 20 | @$(".track-row-#{@model.get("fileIndex")}").addClass "success" 21 | 22 | @model.on "playing", => 23 | @$(".play-control").removeAttr "disabled" 24 | @$(".play-audio").hide() 25 | @$(".pause-audio").show() 26 | @$(".track-position-text").removeClass("blink").text "" 27 | @$(".volume-left").width "0%" 28 | @$(".volume-right").width "0%" 29 | 30 | if @model.get("duration") 31 | @$(".progress-volume").css "cursor", "pointer" 32 | else 33 | @$(".track-position").addClass("progress-striped active") 34 | @setTrackProgress 100 35 | 36 | @model.on "paused", => 37 | @$(".play-audio").show() 38 | @$(".pause-audio").hide() 39 | @$(".volume-left").width "0%" 40 | @$(".volume-right").width "0%" 41 | @$(".track-position-text").addClass "blink" 42 | 43 | @model.on "stopped", => 44 | @$(".play-audio").show() 45 | @$(".pause-audio").hide() 46 | @$(".progress-volume").css "cursor", "" 47 | @$(".track-position").removeClass("progress-striped active") 48 | @setTrackProgress 0 49 | @$(".track-position-text").removeClass("blink").text "" 50 | @$(".volume-left").width "0%" 51 | @$(".volume-right").width "0%" 52 | 53 | @model.on "change:position", => 54 | return unless duration = @model.get("duration") 55 | 56 | position = parseFloat @model.get("position") 57 | 58 | @setTrackProgress 100.0*position/parseFloat(duration) 59 | 60 | @$(".track-position-text"). 61 | text "#{Webcaster.prettifyTime(position)} / #{Webcaster.prettifyTime(duration)}" 62 | 63 | render: -> 64 | @$(".volume-slider").slider 65 | orientation: "vertical" 66 | min: 0 67 | max: 150 68 | value: 100 69 | stop: => 70 | @$("a.ui-slider-handle").tooltip "hide" 71 | slide: (e, ui) => 72 | @model.set trackGain: ui.value 73 | @$("a.ui-slider-handle").tooltip "show" 74 | 75 | @$("a.ui-slider-handle").tooltip 76 | title: => @model.get "trackGain" 77 | trigger: "" 78 | animation: false 79 | placement: "left" 80 | 81 | files = @model.get "files" 82 | 83 | @$(".files-table").empty() 84 | 85 | return this unless files.length > 0 86 | 87 | _.each files, ({file, audio, metadata}, index) => 88 | if audio?.length != 0 89 | time = Webcaster.prettifyTime audio.length 90 | else 91 | time = "N/A" 92 | 93 | if @model.get("fileIndex") == index 94 | klass = "success" 95 | else 96 | klass = "" 97 | 98 | @$(".files-table").append """ 99 | 100 | #{index+1} 101 | #{metadata?.title || "Unknown Title"} 102 | #{metadata?.artist || "Unknown Artist"} 103 | #{time} 104 | 105 | """ 106 | 107 | @$(".playlist-table").show() 108 | 109 | this 110 | 111 | setTrackProgress: (percent) -> 112 | @$(".track-position").width "#{percent*$(".progress-volume").width()/100}px" 113 | @$(".track-position-text,.progress-seek").width $(".progress-volume").width() 114 | 115 | play: (options) -> 116 | @model.stop() 117 | return unless @file = @model.selectFile options 118 | 119 | @$(".play-control").attr disabled: "disabled" 120 | @model.play @file 121 | 122 | onPlay: (e) -> 123 | e.preventDefault() 124 | if @model.isPlaying() 125 | @model.togglePause() 126 | return 127 | 128 | @play() 129 | 130 | onPause: (e) -> 131 | e.preventDefault() 132 | @model.togglePause() 133 | 134 | onPrevious: (e) -> 135 | e.preventDefault() 136 | return unless @model.isPlaying()? 137 | 138 | @play backward: true 139 | 140 | onNext: (e) -> 141 | e.preventDefault() 142 | return unless @model.isPlaying() 143 | 144 | @play() 145 | 146 | onStop: (e) -> 147 | e.preventDefault() 148 | 149 | @$(".track-row").removeClass "success" 150 | @model.stop() 151 | @file = null 152 | 153 | onSeek: (e) -> 154 | e.preventDefault() 155 | 156 | @model.seek ((e.pageX - $(e.target).offset().left) / $(e.target).width()) 157 | 158 | onFiles: -> 159 | files = @$(".files")[0].files 160 | @$(".files").attr disabled: "disabled" 161 | 162 | @model.appendFiles files, => 163 | @$(".files").removeAttr("disabled").val "" 164 | @render() 165 | 166 | onPlayThrough: (e) -> 167 | @model.set playThrough: $(e.target).is(":checked") 168 | 169 | onLoop: (e) -> 170 | @model.set loop: $(e.target).is(":checked") 171 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Webcaster Client 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 |
27 | 120 |
121 |
122 |
123 |

Webcaster Client

124 |
125 |
126 |
127 |
128 |

Playlist 1

129 |
130 |
131 |
132 |
133 | 134 | 135 | 136 | 137 | 138 | 139 |
140 |
141 |
142 |
143 |
144 |
145 | 146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 | 160 |
161 | Add files to playlist: 162 | 163 |
164 |
165 |
166 |
167 |
168 |
169 | 172 |
173 |
174 | 177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |

Mixer

185 |
186 |
187 |
188 |
189 |

Microphone

190 |
191 |
192 |
193 |
194 | 195 | 196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 | 212 |
213 |
214 |
215 |
216 |
217 |
218 |

Playlist 2

219 |
220 |
221 |
222 |
223 | 224 | 225 | 226 | 227 | 228 | 229 |
230 |
231 |
232 |
233 |
234 |
235 | 236 |
237 |
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 | 250 |
251 | Add files to playlist: 252 | 253 |
254 |
255 |
256 |
257 |
258 |
259 | 262 |
263 |
264 | 267 |
268 |
269 |
270 |
271 |
272 |
273 |
274 | 275 | 276 | -------------------------------------------------------------------------------- /js/client.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 2.5.1 2 | (function() { 3 | var Webcaster, base, base1, ref, ref1, 4 | boundMethodCheck = function(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new Error('Bound instance method accessed before binding'); } }; 5 | 6 | navigator.mediaDevices || (navigator.mediaDevices = {}); 7 | 8 | (base = navigator.mediaDevices).getUserMedia || (base.getUserMedia = function(constraints) { 9 | var fn; 10 | fn = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia; 11 | if (fn == null) { 12 | return Promise.reject(new Error("getUserMedia is not implemented in this browser")); 13 | } 14 | return new Promise(function(resolve, reject) { 15 | return fn.call(navigator, constraints, resolve, reject); 16 | }); 17 | }); 18 | 19 | (base1 = navigator.mediaDevices).enumerateDevices || (base1.enumerateDevices = function() { 20 | return Promise.reject(new Error("enumerateDevices is not implemented on this browser")); 21 | }); 22 | 23 | window.Webcaster = Webcaster = { 24 | View: {}, 25 | Model: {}, 26 | Source: {}, 27 | prettifyTime: function(time) { 28 | var hours, minutes, result, seconds; 29 | hours = parseInt(time / 3600); 30 | time %= 3600; 31 | minutes = parseInt(time / 60); 32 | seconds = parseInt(time % 60); 33 | if (minutes < 10) { 34 | minutes = `0${minutes}`; 35 | } 36 | if (seconds < 10) { 37 | seconds = `0${seconds}`; 38 | } 39 | result = `${minutes}:${seconds}`; 40 | if (hours > 0) { 41 | result = `${hours}:${result}`; 42 | } 43 | return result; 44 | } 45 | }; 46 | 47 | Webcaster.Node = (function() { 48 | class Node { 49 | constructor({ 50 | model: model1 51 | }) { 52 | var setContext; 53 | this.startStream = this.startStream.bind(this); 54 | this.stopStream = this.stopStream.bind(this); 55 | this.model = model1; 56 | setContext = () => { 57 | var channels, sampleRate; 58 | sampleRate = this.model.get("samplerate"); 59 | channels = this.model.get("channels"); 60 | this.context = new AudioContext({ 61 | sampleRate: sampleRate 62 | }); 63 | this.sink = this.context.createScriptProcessor(256, 2, 2); 64 | this.sink.onaudioprocess = (buf) => { 65 | var channel, channelData, j, ref, results; 66 | channelData = buf.inputBuffer.getChannelData(channel); 67 | results = []; 68 | for (channel = j = 0, ref = buf.inputBuffer.numberOfChannels - 1; (0 <= ref ? j <= ref : j >= ref); channel = 0 <= ref ? ++j : --j) { 69 | results.push(buf.outputBuffer.getChannelData(channel).set(channelData)); 70 | } 71 | return results; 72 | }; 73 | this.playThrough = this.context.createScriptProcessor(256, 2, 2); 74 | this.playThrough.onaudioprocess = (buf) => { 75 | var channel, channelData, j, ref, results; 76 | channelData = buf.inputBuffer.getChannelData(channel); 77 | results = []; 78 | for (channel = j = 0, ref = buf.inputBuffer.numberOfChannels - 1; (0 <= ref ? j <= ref : j >= ref); channel = 0 <= ref ? ++j : --j) { 79 | if (this.model.get("passThrough")) { 80 | results.push(buf.outputBuffer.getChannelData(channel).set(channelData)); 81 | } else { 82 | results.push(buf.outputBuffer.getChannelData(channel).set(new Float32Array(channelData.length))); 83 | } 84 | } 85 | return results; 86 | }; 87 | this.sink.connect(this.playThrough); 88 | this.playThrough.connect(this.context.destination); 89 | this.streamNode = this.context.createMediaStreamDestination(); 90 | this.streamNode.channelCount = channels; 91 | return this.sink.connect(this.streamNode); 92 | }; 93 | setContext(); 94 | this.model.on("change:samplerate", setContext); 95 | this.model.on("change:channels", setContext); 96 | } 97 | 98 | registerSource() { 99 | return this.model.set("playing", this.model.get("playing") + 1); 100 | } 101 | 102 | unregisterSource() { 103 | return this.model.set("playing", Math.max(0, this.model.get("playing") - 1)); 104 | } 105 | 106 | startStream() { 107 | var audioBitrate, mimeType, url, videoBitrate; 108 | this.model.set("streaming", true); 109 | this.context.resume(); 110 | mimeType = this.model.get("mimeType"); 111 | audioBitrate = Number(this.model.get("audioBitrate")) * 1000; 112 | videoBitrate = Number(this.model.get("videoBitrate")) * 1000000; 113 | url = this.model.get("url"); 114 | if (this.model.get("camera")) { 115 | this.streamNode.stream.addTrack($(".camera-preview").get(0).captureStream().getTracks()[0]); 116 | } 117 | this.mediaRecorder = new MediaRecorder(this.streamNode.stream, { 118 | mimeType: mimeType, 119 | audioBitsPerSecond: audioBitrate, 120 | videoBitsPerSecond: videoBitrate 121 | }); 122 | this.socket = new Webcast.Socket({ 123 | mediaRecorder: this.mediaRecorder, 124 | url: url 125 | }); 126 | return this.mediaRecorder.start(1000); 127 | } 128 | 129 | stopStream() { 130 | var ref; 131 | if ((ref = this.mediaRecorder) != null) { 132 | ref.stop(); 133 | } 134 | return this.model.set("streaming", false); 135 | } 136 | 137 | createAudioSource({file, audio}, model, cb) { 138 | var el, source; 139 | el = new Audio(URL.createObjectURL(file)); 140 | el.controls = false; 141 | el.autoplay = false; 142 | el.loop = false; 143 | el.addEventListener("ended", () => { 144 | return model.onEnd(); 145 | }); 146 | source = null; 147 | return el.addEventListener("canplay", () => { 148 | if (source != null) { 149 | return; 150 | } 151 | source = this.context.createMediaElementSource(el); 152 | source.play = function() { 153 | return el.play(); 154 | }; 155 | source.position = function() { 156 | return el.currentTime; 157 | }; 158 | source.duration = function() { 159 | return el.duration; 160 | }; 161 | source.paused = function() { 162 | return el.paused; 163 | }; 164 | source.stop = function() { 165 | el.pause(); 166 | return el.remove(); 167 | }; 168 | source.pause = function() { 169 | return el.pause(); 170 | }; 171 | source.seek = function(percent) { 172 | var time; 173 | time = percent * parseFloat(audio.length); 174 | el.currentTime = time; 175 | return time; 176 | }; 177 | return cb(source); 178 | }); 179 | } 180 | 181 | createFileSource(file, model, cb) { 182 | var ref; 183 | if ((ref = this.source) != null) { 184 | ref.disconnect(); 185 | } 186 | return this.createAudioSource(file, model, cb); 187 | } 188 | 189 | createMicrophoneSource(constraints, cb) { 190 | return navigator.mediaDevices.getUserMedia(constraints).then((stream) => { 191 | var source; 192 | source = this.context.createMediaStreamSource(stream); 193 | source.stop = function() { 194 | var ref; 195 | return (ref = stream.getAudioTracks()) != null ? ref[0].stop() : void 0; 196 | }; 197 | return cb(source); 198 | }); 199 | } 200 | 201 | sendMetadata(data) { 202 | var ref; 203 | return (ref = this.socket) != null ? ref.sendMetadata(data) : void 0; 204 | } 205 | 206 | }; 207 | 208 | _.extend(Node.prototype, Backbone.Events); 209 | 210 | return Node; 211 | 212 | }).call(this); 213 | 214 | ref = Webcaster.Model.Track = class Track extends Backbone.Model { 215 | constructor() { 216 | super(...arguments); 217 | this.setTrackGain = this.setTrackGain.bind(this); 218 | } 219 | 220 | initialize(attributes, options) { 221 | this.node = options.node; 222 | this.mixer = options.mixer; 223 | this.mixer.on("cue", () => { 224 | return this.set({ 225 | passThrough: false 226 | }); 227 | }); 228 | this.on("change:trackGain", this.setTrackGain); 229 | return this.on("ended", this.stop); 230 | } 231 | 232 | togglePassThrough() { 233 | var passThrough; 234 | passThrough = this.get("passThrough"); 235 | if (passThrough) { 236 | return this.set({ 237 | passThrough: false 238 | }); 239 | } else { 240 | this.mixer.trigger("cue"); 241 | return this.set({ 242 | passThrough: true 243 | }); 244 | } 245 | } 246 | 247 | isPlaying() { 248 | return this.source != null; 249 | } 250 | 251 | createControlsNode() { 252 | var bufferLength, bufferLog, bufferSize, log10, source; 253 | bufferSize = 4096; 254 | bufferLength = parseFloat(bufferSize) / parseFloat(this.node.context.sampleRate); 255 | bufferLog = Math.log(parseFloat(bufferSize)); 256 | log10 = 2.0 * Math.log(10); 257 | source = this.node.context.createScriptProcessor(bufferSize, 2, 2); 258 | source.onaudioprocess = (buf) => { 259 | var channel, channelData, i, j, k, ref1, ref2, ref3, results, ret, rms, volume; 260 | ret = {}; 261 | if (((ref1 = this.source) != null ? ref1.position : void 0) != null) { 262 | ret["position"] = this.source.position(); 263 | } else { 264 | if (this.source != null) { 265 | ret["position"] = parseFloat(this.get("position")) + bufferLength; 266 | } 267 | } 268 | results = []; 269 | for (channel = j = 0, ref2 = buf.inputBuffer.numberOfChannels - 1; (0 <= ref2 ? j <= ref2 : j >= ref2); channel = 0 <= ref2 ? ++j : --j) { 270 | channelData = buf.inputBuffer.getChannelData(channel); 271 | rms = 0.0; 272 | for (i = k = 0, ref3 = channelData.length - 1; (0 <= ref3 ? k <= ref3 : k >= ref3); i = 0 <= ref3 ? ++k : --k) { 273 | rms += Math.pow(channelData[i], 2); 274 | } 275 | volume = 100 * Math.exp((Math.log(rms) - bufferLog) / log10); 276 | if (channel === 0) { 277 | ret["volumeLeft"] = volume; 278 | } else { 279 | ret["volumeRight"] = volume; 280 | } 281 | this.set(ret); 282 | results.push(buf.outputBuffer.getChannelData(channel).set(channelData)); 283 | } 284 | return results; 285 | }; 286 | return source; 287 | } 288 | 289 | createPassThrough() { 290 | var source; 291 | source = this.node.context.createScriptProcessor(256, 2, 2); 292 | source.onaudioprocess = (buf) => { 293 | var channel, channelData, j, ref1, results; 294 | channelData = buf.inputBuffer.getChannelData(channel); 295 | results = []; 296 | for (channel = j = 0, ref1 = buf.inputBuffer.numberOfChannels - 1; (0 <= ref1 ? j <= ref1 : j >= ref1); channel = 0 <= ref1 ? ++j : --j) { 297 | if (this.get("passThrough")) { 298 | results.push(buf.outputBuffer.getChannelData(channel).set(channelData)); 299 | } else { 300 | results.push(buf.outputBuffer.getChannelData(channel).set(new Float32Array(channelData.length))); 301 | } 302 | } 303 | return results; 304 | }; 305 | return source; 306 | } 307 | 308 | setTrackGain() { 309 | boundMethodCheck(this, ref); 310 | if (this.trackGain == null) { 311 | return; 312 | } 313 | return this.trackGain.gain.value = parseFloat(this.get("trackGain")) / 100.0; 314 | } 315 | 316 | sink() { 317 | return this.node.sink; 318 | } 319 | 320 | prepare() { 321 | this.node.registerSource(); 322 | this.controlsNode = this.createControlsNode(); 323 | this.controlsNode.connect(this.sink()); 324 | this.trackGain = this.node.context.createGain(); 325 | this.trackGain.connect(this.controlsNode); 326 | this.setTrackGain(); 327 | this.destination = this.trackGain; 328 | this.passThrough = this.createPassThrough(); 329 | this.passThrough.connect(this.node.context.destination); 330 | this.destination.connect(this.passThrough); 331 | return this.node.context.resume(); 332 | } 333 | 334 | togglePause() { 335 | var ref1, ref2; 336 | if (((ref1 = this.source) != null ? ref1.pause : void 0) == null) { 337 | return; 338 | } 339 | if ((ref2 = this.source) != null ? typeof ref2.paused === "function" ? ref2.paused() : void 0 : void 0) { 340 | this.source.play(); 341 | return this.trigger("playing"); 342 | } else { 343 | this.source.pause(); 344 | return this.trigger("paused"); 345 | } 346 | } 347 | 348 | stop() { 349 | var ref1, ref2, ref3, ref4, ref5; 350 | if ((ref1 = this.source) != null) { 351 | if (typeof ref1.stop === "function") { 352 | ref1.stop(); 353 | } 354 | } 355 | if ((ref2 = this.source) != null) { 356 | ref2.disconnect(); 357 | } 358 | if ((ref3 = this.trackGain) != null) { 359 | ref3.disconnect(); 360 | } 361 | if ((ref4 = this.controlsNode) != null) { 362 | ref4.disconnect(); 363 | } 364 | if ((ref5 = this.passThrough) != null) { 365 | ref5.disconnect(); 366 | } 367 | this.source = this.trackGain = this.controlsNode = this.passThrough = null; 368 | this.set({ 369 | position: 0.0 370 | }); 371 | this.node.unregisterSource(); 372 | return this.trigger("stopped"); 373 | } 374 | 375 | seek(percent) { 376 | var position, ref1; 377 | if (!(position = (ref1 = this.source) != null ? typeof ref1.seek === "function" ? ref1.seek(percent) : void 0 : void 0)) { 378 | return; 379 | } 380 | return this.set({ 381 | position: position 382 | }); 383 | } 384 | 385 | sendMetadata(file) { 386 | return this.node.sendMetadata(file.metadata); 387 | } 388 | 389 | }; 390 | 391 | Webcaster.Model.Microphone = class Microphone extends Webcaster.Model.Track { 392 | initialize() { 393 | return super.initialize(...arguments); 394 | } 395 | 396 | createSource(cb) { 397 | var constraints; 398 | constraints = { 399 | video: false 400 | }; 401 | if (this.get("device")) { 402 | constraints.audio = { 403 | exact: this.get("device") 404 | }; 405 | } else { 406 | constraints.audio = true; 407 | } 408 | return this.node.createMicrophoneSource(constraints, (source1) => { 409 | this.source = source1; 410 | this.source.connect(this.destination); 411 | return typeof cb === "function" ? cb() : void 0; 412 | }); 413 | } 414 | 415 | play() { 416 | this.prepare(); 417 | return this.createSource(() => { 418 | return this.trigger("playing"); 419 | }); 420 | } 421 | 422 | stop() { 423 | var ref1; 424 | if ((ref1 = this.source) != null) { 425 | ref1.disconnect(); 426 | } 427 | return super.stop(...arguments); 428 | } 429 | 430 | }; 431 | 432 | Webcaster.Model.Mixer = class Mixer extends Backbone.Model { 433 | getVolume(position) { 434 | if (position < 0.5) { 435 | return 2 * position; 436 | } 437 | return 1; 438 | } 439 | 440 | getSlider() { 441 | return parseFloat(this.get("slider")) / 100.00; 442 | } 443 | 444 | getLeftVolume() { 445 | return this.getVolume(1.0 - this.getSlider()); 446 | } 447 | 448 | getRightVolume() { 449 | return this.getVolume(this.getSlider()); 450 | } 451 | 452 | }; 453 | 454 | ref1 = Webcaster.Model.Playlist = class Playlist extends Webcaster.Model.Track { 455 | constructor() { 456 | super(...arguments); 457 | this.setMixGain = this.setMixGain.bind(this); 458 | } 459 | 460 | initialize() { 461 | super.initialize(...arguments); 462 | return this.mixer.on("change:slider", () => { 463 | return this.setMixGain(); 464 | }); 465 | } 466 | 467 | prepare() { 468 | this.mixGain = this.node.context.createGain(); 469 | this.mixGain.connect(this.node.sink); 470 | return super.prepare(...arguments); 471 | } 472 | 473 | stop() { 474 | var ref2; 475 | if ((ref2 = this.mixGain) != null) { 476 | ref2.disconnect(); 477 | } 478 | return super.stop(...arguments); 479 | } 480 | 481 | sink() { 482 | return this.mixGain; 483 | } 484 | 485 | setMixGain() { 486 | boundMethodCheck(this, ref1); 487 | if (this.mixGain == null) { 488 | return; 489 | } 490 | if (this.get("side") === "left") { 491 | return this.mixGain.gain.value = this.mixer.getLeftVolume(); 492 | } else { 493 | return this.mixGain.gain.value = this.mixer.getRightVolume(); 494 | } 495 | } 496 | 497 | appendFiles(newFiles, cb) { 498 | var addFile, files, i, j, onDone, ref2, results; 499 | files = this.get("files"); 500 | onDone = _.after(newFiles.length, () => { 501 | this.set({ 502 | files: files 503 | }); 504 | return typeof cb === "function" ? cb() : void 0; 505 | }); 506 | addFile = function(file) { 507 | return file.readTaglibMetadata((data) => { 508 | files.push({ 509 | file: file, 510 | audio: data.audio, 511 | metadata: data.metadata 512 | }); 513 | return onDone(); 514 | }); 515 | }; 516 | results = []; 517 | for (i = j = 0, ref2 = newFiles.length - 1; (0 <= ref2 ? j <= ref2 : j >= ref2); i = 0 <= ref2 ? ++j : --j) { 518 | results.push(addFile(newFiles[i])); 519 | } 520 | return results; 521 | } 522 | 523 | selectFile(options = {}) { 524 | var file, files, index; 525 | files = this.get("files"); 526 | index = this.get("fileIndex"); 527 | if (files.length === 0) { 528 | return; 529 | } 530 | index += options.backward ? -1 : 1; 531 | if (index < 0) { 532 | index = files.length - 1; 533 | } 534 | if (index >= files.length) { 535 | if (!this.get("loop")) { 536 | this.set({ 537 | fileIndex: -1 538 | }); 539 | return; 540 | } 541 | if (index < 0) { 542 | index = files.length - 1; 543 | } else { 544 | index = 0; 545 | } 546 | } 547 | file = files[index]; 548 | this.set({ 549 | fileIndex: index 550 | }); 551 | return file; 552 | } 553 | 554 | play(file) { 555 | this.prepare(); 556 | this.setMixGain(); 557 | return this.node.createFileSource(file, this, (source1) => { 558 | var ref2; 559 | this.source = source1; 560 | this.source.connect(this.destination); 561 | if (this.source.duration != null) { 562 | this.set({ 563 | duration: this.source.duration() 564 | }); 565 | } else { 566 | if (((ref2 = file.audio) != null ? ref2.length : void 0) != null) { 567 | this.set({ 568 | duration: parseFloat(file.audio.length) 569 | }); 570 | } 571 | } 572 | this.source.play(file); 573 | return this.trigger("playing"); 574 | }); 575 | } 576 | 577 | onEnd() { 578 | this.stop(); 579 | if (this.get("playThrough")) { 580 | return this.play(this.selectFile()); 581 | } 582 | } 583 | 584 | }; 585 | 586 | Webcaster.Model.Settings = class Settings extends Backbone.Model { 587 | initialize(attributes, options) { 588 | this.mixer = options.mixer; 589 | return this.mixer.on("cue", () => { 590 | return this.set({ 591 | passThrough: false 592 | }); 593 | }); 594 | } 595 | 596 | togglePassThrough() { 597 | var passThrough; 598 | passThrough = this.get("passThrough"); 599 | if (passThrough) { 600 | return this.set({ 601 | passThrough: false 602 | }); 603 | } else { 604 | this.mixer.trigger("cue"); 605 | return this.set({ 606 | passThrough: true 607 | }); 608 | } 609 | } 610 | 611 | }; 612 | 613 | Webcaster.View.Track = class Track extends Backbone.View { 614 | initialize() { 615 | this.model.on("change:passThrough", () => { 616 | if (this.model.get("passThrough")) { 617 | return this.$(".passThrough").addClass("btn-cued").removeClass("btn-info"); 618 | } else { 619 | return this.$(".passThrough").addClass("btn-info").removeClass("btn-cued"); 620 | } 621 | }); 622 | this.model.on("change:volumeLeft", () => { 623 | return this.$(".volume-left").width(`${this.model.get("volumeLeft")}%`); 624 | }); 625 | return this.model.on("change:volumeRight", () => { 626 | return this.$(".volume-right").width(`${this.model.get("volumeRight")}%`); 627 | }); 628 | } 629 | 630 | onPassThrough(e) { 631 | e.preventDefault(); 632 | return this.model.togglePassThrough(); 633 | } 634 | 635 | onSubmit(e) { 636 | return e.preventDefault(); 637 | } 638 | 639 | }; 640 | 641 | Webcaster.View.Microphone = (function() { 642 | class Microphone extends Webcaster.View.Track { 643 | initialize() { 644 | super.initialize(...arguments); 645 | this.model.on("playing", () => { 646 | this.$(".play-control").removeAttr("disabled"); 647 | this.$(".record-audio").addClass("btn-recording"); 648 | this.$(".volume-left").width("0%"); 649 | return this.$(".volume-right").width("0%"); 650 | }); 651 | return this.model.on("stopped", () => { 652 | this.$(".record-audio").removeClass("btn-recording"); 653 | this.$(".volume-left").width("0%"); 654 | return this.$(".volume-right").width("0%"); 655 | }); 656 | } 657 | 658 | render() { 659 | this.$(".microphone-slider").slider({ 660 | orientation: "vertical", 661 | min: 0, 662 | max: 150, 663 | value: 100, 664 | stop: () => { 665 | return this.$("a.ui-slider-handle").tooltip("hide"); 666 | }, 667 | slide: (e, ui) => { 668 | this.model.set({ 669 | trackGain: ui.value 670 | }); 671 | return this.$("a.ui-slider-handle").tooltip("show"); 672 | } 673 | }); 674 | this.$("a.ui-slider-handle").tooltip({ 675 | title: () => { 676 | return this.model.get("trackGain"); 677 | }, 678 | trigger: "", 679 | animation: false, 680 | placement: "left" 681 | }); 682 | navigator.mediaDevices.getUserMedia({ 683 | audio: true, 684 | video: false 685 | }).then(() => { 686 | return navigator.mediaDevices.enumerateDevices().then((devices) => { 687 | var $select; 688 | devices = _.filter(devices, function({kind, deviceId}) { 689 | return kind === "audioinput"; 690 | }); 691 | if (_.isEmpty(devices)) { 692 | return; 693 | } 694 | $select = this.$(".microphone-entry select"); 695 | _.each(devices, function({label, deviceId}) { 696 | return $select.append(``); 697 | }); 698 | $select.find("option:eq(0)").prop("selected", true); 699 | this.model.set("device", $select.val()); 700 | $select.select(function() { 701 | return this.model.set("device", $select.val()); 702 | }); 703 | return this.$(".microphone-entry").show(); 704 | }); 705 | }); 706 | return this; 707 | } 708 | 709 | onAudioDevice(e) { 710 | return this.model.set({ 711 | device: $(e.target).val() 712 | }); 713 | } 714 | 715 | onRecord(e) { 716 | e.preventDefault(); 717 | if (this.model.isPlaying()) { 718 | return this.model.stop(); 719 | } 720 | this.$(".play-control").attr({ 721 | disabled: "disabled" 722 | }); 723 | return this.model.play(); 724 | } 725 | 726 | }; 727 | 728 | Microphone.prototype.events = { 729 | "click .record-audio": "onRecord", 730 | "click .passThrough": "onPassThrough", 731 | "change .audio-device": "onAudioDevice", 732 | "submit": "onSubmit" 733 | }; 734 | 735 | return Microphone; 736 | 737 | }).call(this); 738 | 739 | Webcaster.View.Camera = (function() { 740 | class Camera extends Backbone.View { 741 | initialize() { 742 | super.initialize(...arguments); 743 | return this.model.on("change:camera", () => { 744 | var ref2; 745 | if (this.model.get("camera")) { 746 | return navigator.mediaDevices.getUserMedia({ 747 | audio: false, 748 | video: true 749 | }).then((stream) => { 750 | var ref2; 751 | this.$(".camera-preview").get(0).srcObject = stream; 752 | this.$(".camera-preview").get(0).play(); 753 | this.model.set("mimeType", (ref2 = this.model.get("videoMimeTypes")[0]) != null ? ref2.value : void 0); 754 | return this.model.set("mimeTypes", this.model.get("videoMimeTypes")); 755 | }); 756 | } else { 757 | this.$(".camera-preview").get(0).srcObject = null; 758 | this.model.set("mimeType", (ref2 = this.model.get("audioMimeTypes")[0]) != null ? ref2.value : void 0); 759 | return this.model.set("mimeTypes", this.model.get("audioMimeTypes")); 760 | } 761 | }); 762 | } 763 | 764 | render() { 765 | navigator.mediaDevices.getUserMedia({ 766 | audio: false, 767 | video: true 768 | }).then(() => { 769 | return this.$(".camera-settings").show(); 770 | }); 771 | return this; 772 | } 773 | 774 | onEnableCamera(e) { 775 | e.preventDefault(); 776 | return this.model.set({ 777 | camera: $(e.target).is(":checked") 778 | }); 779 | } 780 | 781 | }; 782 | 783 | Camera.prototype.events = { 784 | "change .enable-camera": "onEnableCamera" 785 | }; 786 | 787 | return Camera; 788 | 789 | }).call(this); 790 | 791 | Webcaster.View.Mixer = class Mixer extends Backbone.View { 792 | render() { 793 | this.$(".slider").slider({ 794 | stop: () => { 795 | return this.$("a.ui-slider-handle").tooltip("hide"); 796 | }, 797 | slide: (e, ui) => { 798 | this.model.set({ 799 | slider: ui.value 800 | }); 801 | return this.$("a.ui-slider-handle").tooltip("show"); 802 | } 803 | }); 804 | this.$("a.ui-slider-handle").tooltip({ 805 | title: () => { 806 | return this.model.get("slider"); 807 | }, 808 | trigger: "", 809 | animation: false, 810 | placement: "bottom" 811 | }); 812 | return this; 813 | } 814 | 815 | }; 816 | 817 | Webcaster.View.Playlist = (function() { 818 | class Playlist extends Webcaster.View.Track { 819 | initialize() { 820 | super.initialize(...arguments); 821 | this.model.on("change:fileIndex", () => { 822 | this.$(".track-row").removeClass("success"); 823 | return this.$(`.track-row-${this.model.get("fileIndex")}`).addClass("success"); 824 | }); 825 | this.model.on("playing", () => { 826 | this.$(".play-control").removeAttr("disabled"); 827 | this.$(".play-audio").hide(); 828 | this.$(".pause-audio").show(); 829 | this.$(".track-position-text").removeClass("blink").text(""); 830 | this.$(".volume-left").width("0%"); 831 | this.$(".volume-right").width("0%"); 832 | if (this.model.get("duration")) { 833 | return this.$(".progress-volume").css("cursor", "pointer"); 834 | } else { 835 | this.$(".track-position").addClass("progress-striped active"); 836 | return this.setTrackProgress(100); 837 | } 838 | }); 839 | this.model.on("paused", () => { 840 | this.$(".play-audio").show(); 841 | this.$(".pause-audio").hide(); 842 | this.$(".volume-left").width("0%"); 843 | this.$(".volume-right").width("0%"); 844 | return this.$(".track-position-text").addClass("blink"); 845 | }); 846 | this.model.on("stopped", () => { 847 | this.$(".play-audio").show(); 848 | this.$(".pause-audio").hide(); 849 | this.$(".progress-volume").css("cursor", ""); 850 | this.$(".track-position").removeClass("progress-striped active"); 851 | this.setTrackProgress(0); 852 | this.$(".track-position-text").removeClass("blink").text(""); 853 | this.$(".volume-left").width("0%"); 854 | return this.$(".volume-right").width("0%"); 855 | }); 856 | return this.model.on("change:position", () => { 857 | var duration, position; 858 | if (!(duration = this.model.get("duration"))) { 859 | return; 860 | } 861 | position = parseFloat(this.model.get("position")); 862 | this.setTrackProgress(100.0 * position / parseFloat(duration)); 863 | return this.$(".track-position-text").text(`${Webcaster.prettifyTime(position)} / ${Webcaster.prettifyTime(duration)}`); 864 | }); 865 | } 866 | 867 | render() { 868 | var files; 869 | this.$(".volume-slider").slider({ 870 | orientation: "vertical", 871 | min: 0, 872 | max: 150, 873 | value: 100, 874 | stop: () => { 875 | return this.$("a.ui-slider-handle").tooltip("hide"); 876 | }, 877 | slide: (e, ui) => { 878 | this.model.set({ 879 | trackGain: ui.value 880 | }); 881 | return this.$("a.ui-slider-handle").tooltip("show"); 882 | } 883 | }); 884 | this.$("a.ui-slider-handle").tooltip({ 885 | title: () => { 886 | return this.model.get("trackGain"); 887 | }, 888 | trigger: "", 889 | animation: false, 890 | placement: "left" 891 | }); 892 | files = this.model.get("files"); 893 | this.$(".files-table").empty(); 894 | if (!(files.length > 0)) { 895 | return this; 896 | } 897 | _.each(files, ({file, audio, metadata}, index) => { 898 | var klass, time; 899 | if ((audio != null ? audio.length : void 0) !== 0) { 900 | time = Webcaster.prettifyTime(audio.length); 901 | } else { 902 | time = "N/A"; 903 | } 904 | if (this.model.get("fileIndex") === index) { 905 | klass = "success"; 906 | } else { 907 | klass = ""; 908 | } 909 | return this.$(".files-table").append(` 910 | ${index + 1} 911 | ${(metadata != null ? metadata.title : void 0) || "Unknown Title"} 912 | ${(metadata != null ? metadata.artist : void 0) || "Unknown Artist"} 913 | ${time} 914 | `); 915 | }); 916 | this.$(".playlist-table").show(); 917 | return this; 918 | } 919 | 920 | setTrackProgress(percent) { 921 | this.$(".track-position").width(`${percent * $(".progress-volume").width() / 100}px`); 922 | return this.$(".track-position-text,.progress-seek").width($(".progress-volume").width()); 923 | } 924 | 925 | play(options) { 926 | this.model.stop(); 927 | if (!(this.file = this.model.selectFile(options))) { 928 | return; 929 | } 930 | this.$(".play-control").attr({ 931 | disabled: "disabled" 932 | }); 933 | return this.model.play(this.file); 934 | } 935 | 936 | onPlay(e) { 937 | e.preventDefault(); 938 | if (this.model.isPlaying()) { 939 | this.model.togglePause(); 940 | return; 941 | } 942 | return this.play(); 943 | } 944 | 945 | onPause(e) { 946 | e.preventDefault(); 947 | return this.model.togglePause(); 948 | } 949 | 950 | onPrevious(e) { 951 | e.preventDefault(); 952 | if (this.model.isPlaying() == null) { 953 | return; 954 | } 955 | return this.play({ 956 | backward: true 957 | }); 958 | } 959 | 960 | onNext(e) { 961 | e.preventDefault(); 962 | if (!this.model.isPlaying()) { 963 | return; 964 | } 965 | return this.play(); 966 | } 967 | 968 | onStop(e) { 969 | e.preventDefault(); 970 | this.$(".track-row").removeClass("success"); 971 | this.model.stop(); 972 | return this.file = null; 973 | } 974 | 975 | onSeek(e) { 976 | e.preventDefault(); 977 | return this.model.seek((e.pageX - $(e.target).offset().left) / $(e.target).width()); 978 | } 979 | 980 | onFiles() { 981 | var files; 982 | files = this.$(".files")[0].files; 983 | this.$(".files").attr({ 984 | disabled: "disabled" 985 | }); 986 | return this.model.appendFiles(files, () => { 987 | this.$(".files").removeAttr("disabled").val(""); 988 | return this.render(); 989 | }); 990 | } 991 | 992 | onPlayThrough(e) { 993 | return this.model.set({ 994 | playThrough: $(e.target).is(":checked") 995 | }); 996 | } 997 | 998 | onLoop(e) { 999 | return this.model.set({ 1000 | loop: $(e.target).is(":checked") 1001 | }); 1002 | } 1003 | 1004 | }; 1005 | 1006 | Playlist.prototype.events = { 1007 | "click .play-audio": "onPlay", 1008 | "click .pause-audio": "onPause", 1009 | "click .previous": "onPrevious", 1010 | "click .next": "onNext", 1011 | "click .stop": "onStop", 1012 | "click .progress-seek": "onSeek", 1013 | "click .passThrough": "onPassThrough", 1014 | "change .files": "onFiles", 1015 | "change .playThrough": "onPlayThrough", 1016 | "change .loop": "onLoop", 1017 | "submit": "onSubmit" 1018 | }; 1019 | 1020 | return Playlist; 1021 | 1022 | }).call(this); 1023 | 1024 | Webcaster.View.Settings = (function() { 1025 | class Settings extends Backbone.View { 1026 | initialize({node}) { 1027 | this.node = node; 1028 | this.model.on("change:mimeTypes", () => { 1029 | return this.setFormats(); 1030 | }); 1031 | this.model.on("change:passThrough", () => { 1032 | if (this.model.get("passThrough")) { 1033 | return this.$(".passThrough").addClass("btn-cued").removeClass("btn-info"); 1034 | } else { 1035 | return this.$(".passThrough").addClass("btn-info").removeClass("btn-cued"); 1036 | } 1037 | }); 1038 | this.model.on("change:playing", () => { 1039 | if (this.model.get("playing") > 0) { 1040 | return this.setPlaying(); 1041 | } else { 1042 | return this.setNotPlaying(); 1043 | } 1044 | }); 1045 | this.model.on("change:streaming", () => { 1046 | if (this.model.get("streaming")) { 1047 | return this.setStreaming(); 1048 | } else { 1049 | return this.setNotStreaming(); 1050 | } 1051 | }); 1052 | return this.model.on("change:camera", () => { 1053 | if (this.model.get("camera")) { 1054 | return this.$(".video-settings").show(); 1055 | } else { 1056 | return this.$(".video-settings").hide(); 1057 | } 1058 | }); 1059 | } 1060 | 1061 | setPlaying() { 1062 | return this.$(".samplerate, .channels").attr({ 1063 | disabled: "disabled" 1064 | }); 1065 | } 1066 | 1067 | setNotPlaying() { 1068 | return this.$(".samplerate, .channels").removeAttr("disabled"); 1069 | } 1070 | 1071 | setStreaming() { 1072 | this.setPlaying(); 1073 | this.$(".stop-stream").show(); 1074 | this.$(".start-stream").hide(); 1075 | this.$(".mimeType, .audio-bitrate, .video-bitrate, .url").attr({ 1076 | disabled: "disabled" 1077 | }); 1078 | return this.$(".manual-metadata, .update-metadata").removeAttr("disabled"); 1079 | } 1080 | 1081 | setNotStreaming() { 1082 | if (!this.model.get("playing")) { 1083 | this.setNotPlaying(); 1084 | } 1085 | this.$(".stop-stream").hide(); 1086 | this.$(".start-stream").show(); 1087 | this.$(".mimeType, .audio-bitrate, .video-bitrate, .url").removeAttr("disabled"); 1088 | return this.$(".manual-metadata, .update-metadata").attr({ 1089 | disabled: "disabled" 1090 | }); 1091 | } 1092 | 1093 | setFormats() { 1094 | var mimeType; 1095 | mimeType = this.model.get("mimeType"); 1096 | this.$(".mimeType").empty(); 1097 | return _.each(this.model.get("mimeTypes"), ({name, value}) => { 1098 | var selected; 1099 | selected = mimeType === value ? "selected" : ""; 1100 | return $(``).appendTo(this.$(".mimeType")); 1101 | }); 1102 | } 1103 | 1104 | render() { 1105 | var audioBitrate, samplerate, videoBitrate; 1106 | this.setFormats(); 1107 | samplerate = this.model.get("samplerate"); 1108 | this.$(".samplerate").empty(); 1109 | _.each(this.model.get("samplerates"), (rate) => { 1110 | var selected; 1111 | selected = samplerate === rate ? "selected" : ""; 1112 | return $(``).appendTo(this.$(".samplerate")); 1113 | }); 1114 | audioBitrate = this.model.get("audioBitrate"); 1115 | this.$(".audio-bitrate").empty(); 1116 | _.each(this.model.get("audioBitrates"), (rate) => { 1117 | var selected; 1118 | selected = audioBitrate === rate ? "selected" : ""; 1119 | return $(``).appendTo(this.$(".audio-bitrate")); 1120 | }); 1121 | videoBitrate = this.model.get("videoBitrate"); 1122 | this.$(".video-bitrate").empty(); 1123 | _.each(this.model.get("videoBitrates"), (rate) => { 1124 | var selected; 1125 | selected = videoBitrate === rate ? "selected" : ""; 1126 | return $(``).appendTo(this.$(".video-bitrate")); 1127 | }); 1128 | return this; 1129 | } 1130 | 1131 | onUrl() { 1132 | return this.model.set({ 1133 | url: this.$(".url").val() 1134 | }); 1135 | } 1136 | 1137 | onEncoder(e) { 1138 | return this.model.set({ 1139 | encoder: $(e.target).val() 1140 | }); 1141 | } 1142 | 1143 | onChannels(e) { 1144 | return this.model.set({ 1145 | channels: parseInt($(e.target).val()) 1146 | }); 1147 | } 1148 | 1149 | onMimeType(e) { 1150 | return this.model.set({ 1151 | mimeType: $(e.target).val() 1152 | }); 1153 | } 1154 | 1155 | onSamplerate(e) { 1156 | return this.model.set({ 1157 | samplerate: parseInt($(e.target).val()) 1158 | }); 1159 | } 1160 | 1161 | onAudioBitrate(e) { 1162 | return this.model.set({ 1163 | audioBitrate: parseInt($(e.target).val()) 1164 | }); 1165 | } 1166 | 1167 | onVideoBitrate(e) { 1168 | return this.model.set({ 1169 | videoBitrate: parseInt($(e.target).val()) 1170 | }); 1171 | } 1172 | 1173 | onAsynchronous(e) { 1174 | return this.model.set({ 1175 | asynchronous: $(e.target).is(":checked") 1176 | }); 1177 | } 1178 | 1179 | onPassThrough(e) { 1180 | e.preventDefault(); 1181 | return this.model.togglePassThrough(); 1182 | } 1183 | 1184 | onStart(e) { 1185 | e.preventDefault(); 1186 | return this.node.startStream(); 1187 | } 1188 | 1189 | onStop(e) { 1190 | e.preventDefault(); 1191 | return this.node.stopStream(); 1192 | } 1193 | 1194 | onMetadataUpdate(e) { 1195 | var artist, title; 1196 | e.preventDefault(); 1197 | title = this.$(".manual-metadata.artist").val(); 1198 | artist = this.$(".manual-metadata.title").val(); 1199 | if (!(artist !== "" || title !== "")) { 1200 | return; 1201 | } 1202 | this.node.sendMetadata({ 1203 | artist: artist, 1204 | title: title 1205 | }); 1206 | return this.$(".metadata-updated").show(400, () => { 1207 | var cb; 1208 | cb = () => { 1209 | return this.$(".metadata-updated").hide(400); 1210 | }; 1211 | return setTimeout(cb, 2000); 1212 | }); 1213 | } 1214 | 1215 | onSubmit(e) { 1216 | return e.preventDefault(); 1217 | } 1218 | 1219 | }; 1220 | 1221 | Settings.prototype.events = { 1222 | "change .url": "onUrl", 1223 | "change input.encoder": "onEncoder", 1224 | "change input.channels": "onChannels", 1225 | "change .mimeType": "onMimeType", 1226 | "change .samplerate": "onSamplerate", 1227 | "change .audio-bitrate": "onAudioBitrate", 1228 | "change .video-bitrate": "onVideoBitrate", 1229 | "change .asynchronous": "onAsynchronous", 1230 | "click .passThrough": "onPassThrough", 1231 | "click .start-stream": "onStart", 1232 | "click .stop-stream": "onStop", 1233 | "click .update-metadata": "onMetadataUpdate", 1234 | "submit": "onSubmit" 1235 | }; 1236 | 1237 | return Settings; 1238 | 1239 | }).call(this); 1240 | 1241 | $(function() { 1242 | var audioMimeTypes, enabledMimeTypes, ref2, videoMimeTypes; 1243 | Webcaster.mixer = new Webcaster.Model.Mixer({ 1244 | slider: 0 1245 | }); 1246 | enabledMimeTypes = (types) => { 1247 | return _.filter(types, ({value}) => { 1248 | return MediaRecorder.isTypeSupported(value); 1249 | }); 1250 | }; 1251 | audioMimeTypes = enabledMimeTypes([ 1252 | { 1253 | name: "Opus audio", 1254 | value: "audio/webm;codecs=opus" 1255 | } 1256 | ]); 1257 | videoMimeTypes = enabledMimeTypes([ 1258 | { 1259 | name: "Opus audio/h264 video", 1260 | value: "video/webm;codecs=h264,opus" 1261 | }, 1262 | { 1263 | name: "Opus audio/vp9 video", 1264 | value: "video/webm;codecs=vp9,opus" 1265 | }, 1266 | { 1267 | name: "Opus audio/vp8 video", 1268 | value: "video/webm;codecs=vp8,opus" 1269 | } 1270 | ]); 1271 | Webcaster.settings = new Webcaster.Model.Settings({ 1272 | url: "ws://source:hackme@localhost:8080/mount", 1273 | audioBitrate: 128, 1274 | audioBitrates: [8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 192, 224, 256, 320], 1275 | videoBitrate: 2.5, 1276 | videoBitrates: [2.5, 3.5, 5, 7, 10], 1277 | samplerate: 44100, 1278 | samplerates: [8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000], 1279 | channels: 2, 1280 | mimeTypes: audioMimeTypes, 1281 | audioMimeTypes: audioMimeTypes, 1282 | videoMimeTypes: videoMimeTypes, 1283 | mimeType: (ref2 = audioMimeTypes[0]) != null ? ref2.value : void 0, 1284 | passThrough: false, 1285 | camera: false, 1286 | streaming: false, 1287 | playing: 0 1288 | }, { 1289 | mixer: Webcaster.mixer 1290 | }); 1291 | Webcaster.node = new Webcaster.Node({ 1292 | model: Webcaster.settings 1293 | }); 1294 | _.extend(Webcaster, { 1295 | views: { 1296 | settings: new Webcaster.View.Settings({ 1297 | model: Webcaster.settings, 1298 | node: Webcaster.node, 1299 | el: $("div.settings") 1300 | }), 1301 | mixer: new Webcaster.View.Mixer({ 1302 | model: Webcaster.mixer, 1303 | el: $("div.mixer") 1304 | }), 1305 | microphone: new Webcaster.View.Microphone({ 1306 | model: new Webcaster.Model.Microphone({ 1307 | trackGain: 100, 1308 | passThrough: false 1309 | }, { 1310 | mixer: Webcaster.mixer, 1311 | node: Webcaster.node 1312 | }), 1313 | el: $("div.microphone") 1314 | }), 1315 | camera: new Webcaster.View.Camera({ 1316 | model: Webcaster.settings, 1317 | el: $("div.camera") 1318 | }), 1319 | playlistLeft: new Webcaster.View.Playlist({ 1320 | model: new Webcaster.Model.Playlist({ 1321 | side: "left", 1322 | files: [], 1323 | fileIndex: -1, 1324 | volumeLeft: 0, 1325 | volumeRight: 0, 1326 | trackGain: 100, 1327 | passThrough: false, 1328 | playThrough: true, 1329 | position: 0.0, 1330 | loop: false 1331 | }, { 1332 | mixer: Webcaster.mixer, 1333 | node: Webcaster.node 1334 | }), 1335 | el: $("div.playlist-left") 1336 | }), 1337 | playlistRight: new Webcaster.View.Playlist({ 1338 | model: new Webcaster.Model.Playlist({ 1339 | side: "right", 1340 | files: [], 1341 | fileIndex: -1, 1342 | volumeLeft: 0, 1343 | volumeRight: 0, 1344 | trackGain: 100, 1345 | passThrough: false, 1346 | playThrough: true, 1347 | position: 0.0, 1348 | loop: false 1349 | }, { 1350 | mixer: Webcaster.mixer, 1351 | node: Webcaster.node 1352 | }), 1353 | el: $("div.playlist-right") 1354 | }) 1355 | } 1356 | }); 1357 | return _.invoke(Webcaster.views, "render"); 1358 | }); 1359 | 1360 | }).call(this); 1361 | --------------------------------------------------------------------------------