├── .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 |