├── .gitignore ├── .vscode └── launch.json ├── README.md ├── app ├── CallController.js ├── ConfigPage.js ├── DialPage.js ├── app.js └── utils │ ├── dtmf.js │ └── waveform.js ├── chrome-extension ├── _locales │ ├── en │ │ └── messages.json │ └── pt_BR │ │ └── messages.json ├── background.js ├── content.js ├── img │ ├── icon_128.png │ └── icon_16.png └── manifest.json ├── css ├── bulma.override.css └── style.css ├── dist ├── css │ └── app.min.css └── js │ ├── contrib.bundle.js │ └── main.min.js ├── docs ├── preview-1.png └── preview-1400x560.png ├── gulpfile.js ├── index.html ├── index.src.html ├── locales ├── app-en-US.json ├── app-en.json └── app-pt.json ├── package.json └── pages └── ConfigPage.html /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | chrome-extension/chrome-extension.zip 60 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Launch Chrome", 11 | "url": "http://localhost:5000", 12 | "webRoot": "${workspaceRoot}" 13 | 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webphone-sip 2 | WebRTC SIP based VoIP client software (+chrome extension) 3 | 4 | It allows you to make calls using your browser in an extremely productive way. 5 | 6 | ![Preview](/docs/preview-1400x560.png?raw=true "Preview") 7 | 8 | ## Features 9 | * Make and get calls 10 | * Audio effects using JS Audio API (Ex.: DTMF) 11 | * Phone Controls - HOLD / MUTE / STOP 12 | * Visual Effects in Calls (waveform viewer) 13 | * ONLY JAVA-SCRIPT (using SIP.js) 14 | * Chrome Extension for Click-To-CALL 15 | * Internationalization Support 16 | 17 | ### TODO 18 | * Call History 19 | * WebPack build 20 | * Receive Calls "in Backgruound" 21 | 22 | 23 | ## Chrome Extension 24 | 25 | Chrome Extension allows you to turn phone numbers and link with the extension to make calls quickly (Click-To-Call). 26 | This allows integration with any CRM. In the menu you also have an option to make the call. 27 | 28 | https://chrome.google.com/webstore/detail/webphone/mcajodgaocmkmmomogbefkghjepgilnc 29 | 30 | ## Ready to use 31 | I did a free version (hosted in github) that is used by the chrome extension (as popUP). 32 | If you improve this code, automatically it's reflected in the extension. 33 | Please send me feedback if you will use. ;) 34 | 35 | ### Requirements 36 | * Chrome / Firefox 37 | * [FreeSwitch](https://freeswitch.org/confluence/display/FREESWITCH/Linux) (+WebRTC) ([guide](https://sipjs.com/guides/server-configuration/freeswitch/)) 38 | * Need Https (see) 39 | 40 | 41 | PS: I have not done any testing using asterisk so far 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /app/CallController.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Controll calling processes and interact with SIP.js 4 | */ 5 | var CallController = (function () { 6 | 7 | var C = { 8 | STATUS_NULL: 0, 9 | STATUS_NEW: 1, 10 | STATUS_CONNECTING: 2, 11 | STATUS_CONNECTED: 3, 12 | STATUS_COMPLETED: 4 13 | }; 14 | 15 | var sipPhone; // SIP.js 16 | var callListener; // Send notifications to DialPage (call-in, call-out, etc...) 17 | 18 | var accountConfig; 19 | 20 | var public = {}; 21 | 22 | public.init = function (config, listener) { 23 | accountConfig = config; 24 | callListener = listener; 25 | initPhone(); 26 | }; 27 | 28 | public.setListener = function (listener) { 29 | callListener = listener; 30 | }; 31 | 32 | function initPhone(){ 33 | 34 | if(sipPhone){ 35 | alert("WARN: OLD call not finished !!"); 36 | return false; 37 | } 38 | 39 | // create audio tag if not exist 40 | var remoteAudio = document.getElementById("remoteAudio"); 41 | if(!remoteAudio){ 42 | var remoteAudio = document.createElement('audio'); 43 | remoteAudio.id = 'remoteAudio'; 44 | document.body.appendChild(remoteAudio); 45 | } 46 | 47 | var config = { 48 | uri: accountConfig.username + '@' + accountConfig.domain, 49 | wsServers: ['wss://' + accountConfig.proxy], // +':7443' 50 | authorizationUser: accountConfig.user, 51 | password: accountConfig.password, 52 | userAgentString : 'WebPhone/'+accountConfig.version 53 | }; 54 | 55 | try { 56 | sipPhone = new SIP.WebRTC.Simple({ 57 | media: { 58 | remote: { 59 | audio: remoteAudio 60 | } 61 | }, 62 | ua: config 63 | }); 64 | } catch (error) { 65 | console.error(error); 66 | alert("ERROR:" +error.message); 67 | throw error; 68 | } 69 | 70 | 71 | window.onunload = onunloadPage; 72 | 73 | sipPhone.on('connected', function(e){ 74 | callListener('connected', e); 75 | // e.sessionDescriptionHandler.peerConnection 76 | // if (pc.getRemoteStreams) { 77 | // remoteStream = pc.getRemoteStreams()[0]; 78 | // } 79 | }); 80 | sipPhone.on('registered', function(e){ 81 | callListener('registered', e); 82 | localStorage.setItem("sip.registered", true); 83 | }); 84 | sipPhone.on('unregistered', function(e){ 85 | callListener('unregistered', e); 86 | localStorage.setItem("sip.registered", false); 87 | }); 88 | sipPhone.on('registrationFailed', function(e){ 89 | callListener('registrationFailed', e); 90 | localStorage.setItem("sip.registered", false); 91 | }); 92 | sipPhone.on('ringing', function(e){ 93 | callListener('call-in', e); 94 | } ); 95 | sipPhone.on('disconnected', function(e){ 96 | callListener('disconnected', e); 97 | }); 98 | sipPhone.on('ended', function(e){callListener('ended', e); }); 99 | 100 | // WebSocket events 101 | sipPhone.ua.on('disconnected', function(e){ callListener('disconnected', e); }); 102 | sipPhone.ua.on('connecting', function(e){ callListener('connecting', e); }); 103 | 104 | callListener('connecting', sipPhone); 105 | } 106 | 107 | public.call = function(number){ 108 | 109 | var fixed = number.replace(/[^a-zA-Z0-9*#/.@]/g,'') 110 | sipPhone.call(fixed); 111 | 112 | callListener('call-out', number); 113 | } 114 | 115 | // Unregister the user agents and terminate all active sessions when the 116 | // window closes or when we navigate away from the page 117 | function onunloadPage(){ 118 | // if(sipPhone) sipPhone.stop(); 119 | } 120 | 121 | public.stop = function(){ 122 | if(sipPhone){ 123 | if(sipPhone.state == 1){ // new 124 | sipPhone.reject(); 125 | }else{ 126 | sipPhone.hangup(); 127 | } 128 | } 129 | } 130 | 131 | public.disconnect = function(){ 132 | if(sipPhone && sipPhone.state != C.STATUS_NULL){ 133 | console.log("removing old connection"); 134 | } 135 | delete sipPhone; 136 | sipPhone = null; 137 | if(callListener) callListener('disconnected'); 138 | } 139 | 140 | public.getState = function(){ 141 | if(sipPhone) return sipPhone.state; 142 | return null; 143 | } 144 | 145 | public.sendDTMF = function(key){ 146 | if(sipPhone) return sipPhone.sendDTMF(key); 147 | return null; 148 | } 149 | 150 | public.answer = function(){ 151 | if(sipPhone) return sipPhone.answer(); 152 | } 153 | 154 | public.setMute = function(value){ 155 | if(value) sipPhone.mute(); 156 | else sipPhone.unmute(); 157 | } 158 | 159 | public.setHold = function(value){ 160 | if(value) sipPhone.hold(); 161 | else sipPhone.unhold(); 162 | } 163 | 164 | // // 165 | // toogleHold: function () { 166 | // if(status){ 167 | // sipPhone.hold(); 168 | // } 169 | // }, 170 | 171 | return public; 172 | 173 | })(); -------------------------------------------------------------------------------- /app/ConfigPage.js: -------------------------------------------------------------------------------- 1 | 2 | var ConfigPage = (function () { 3 | 4 | var $el = null; 5 | 6 | var public = {}; 7 | 8 | var listenerStateChange = function(state){ 9 | 10 | // Broadcast event 11 | App.emit('call::state_change', state); 12 | 13 | if(state == 'registered'){ 14 | localStorage.setItem('config.registered',true); 15 | App.emit('config::registered'); 16 | } 17 | 18 | } 19 | 20 | public.init = function (el) { 21 | $el = el; 22 | 23 | $('form', $el).submit(function(e){ 24 | 25 | e.preventDefault(); 26 | 27 | var config = { 28 | username: $($el).find('[name="username"]').val(), 29 | domain: $($el).find('[name="domain"]').val(), 30 | proxy: $($el).find('[name="proxy"]').val(), 31 | password: $($el).find('[name="password"]').val() 32 | }; 33 | 34 | config.version = App.version; 35 | 36 | localStorage.setItem('sip.account', JSON.stringify(config)); 37 | 38 | 39 | CallController.disconnect(); 40 | CallController.init(config, listenerStateChange); 41 | 42 | }); 43 | 44 | 45 | $('input[type=file]', $el).on('change',loadFromFile); 46 | 47 | $('.btnCancel', $el).click(function(){ 48 | CallController.disconnect(); 49 | localStorage.removeItem('sip.account'); 50 | localStorage.removeItem('config.registered'); 51 | $('form', $el)[0].reset(); 52 | }); 53 | 54 | 55 | } 56 | 57 | public.show = function () { 58 | 59 | loadConfig(); 60 | 61 | } 62 | 63 | function loadConfig(){ 64 | var data = localStorage.getItem('sip.account'); 65 | 66 | if(data){ 67 | data = JSON.parse(data); 68 | $.each(data, function(key, value){ 69 | $('[name='+key+']', $el).val(value); 70 | }); 71 | } 72 | } 73 | 74 | function loadFromFile(e){ 75 | var oFReader = new FileReader(); 76 | oFReader.readAsBinaryString(e.target.files[0]); 77 | oFReader.onload = function (oFREvent) { 78 | localStorage.setItem('sip.account',oFREvent.target.result); 79 | loadConfig(); 80 | }; 81 | } 82 | 83 | return public; 84 | 85 | })(); -------------------------------------------------------------------------------- /app/DialPage.js: -------------------------------------------------------------------------------- 1 | var DialPage = (function () { 2 | 3 | var $el = null; 4 | var $btnCall, $phoneNumber; 5 | var callActive = false; 6 | 7 | var public = {}; 8 | 9 | public.init = function (el) { 10 | 11 | $el = el; 12 | 13 | App.on('call::state_change', onCallStateChange); 14 | 15 | DTMFAudio.init(); // init audio buffers 16 | 17 | $btnCall = $("#btnCall"); 18 | $phoneNumber = $("#phoneNumber"); 19 | $phoneNumber.keyup(function (e) { 20 | if (e.keyCode == 13) $btnCall.trigger("click"); 21 | if (e.keyCode == 38 || e.keyCode == 40) { // up-down 22 | $phoneNumber.val(localStorage.getItem('dial.lastNumber')); 23 | } 24 | }); 25 | 26 | 27 | $("#btnMute").on('click', function(){ 28 | var activate = ! ($(this).data('active') || false); 29 | CallController.setMute(activate); 30 | $(this).data('active', activate) 31 | if(activate){ 32 | $(this).removeClass("is-outlined"); 33 | }else{ 34 | $(this).addClass("is-outlined"); 35 | } 36 | }); 37 | 38 | $("#btnHold").on('click', function(){ 39 | var activate = ! ($(this).data('active') || false); 40 | CallController.setHold(activate); 41 | $(this).data('active', activate) 42 | if(activate){ 43 | $(this).removeClass("is-outlined"); 44 | }else{ 45 | $(this).addClass("is-outlined"); 46 | } 47 | }); 48 | 49 | $("#btnStopCall").on('click', function(){ 50 | CallController.stop(); 51 | }); 52 | 53 | $btnCall.on('click', function(){ 54 | 55 | if(callActive){ 56 | CallController.stop(); 57 | }else{ 58 | var number = $phoneNumber.val(); 59 | localStorage.setItem('dial.lastNumber', number); 60 | CallController.call(number); 61 | } 62 | 63 | }); 64 | 65 | // Play tones 66 | $("#caller-digits a").click(function(){ 67 | var text = $(this).text(); 68 | 69 | DTMFAudio.play(text); 70 | 71 | if(callActive) CallController.sendDTMF(text); 72 | else{ 73 | $phoneNumber.val($phoneNumber.val() + text); 74 | } 75 | 76 | }); 77 | 78 | }; 79 | 80 | public.show = function () { 81 | // none 82 | }; 83 | 84 | function onCallStateChange(state, e){ 85 | 86 | var $status = $("#phoneStatus"); 87 | $status.html($loc['status_'+state.replace("-", "_")]); 88 | $status.attr('class', 'tag phoneStatus-'+state); 89 | 90 | // General status 91 | if(state == "connected"){ 92 | callActive = true; 93 | $("footer").addClass('call-active'); 94 | $("#controls-call-active .button").data("active", false); // reset state 95 | }else{ 96 | callActive = false; 97 | $("footer").removeClass('call-active'); 98 | } 99 | 100 | // Sound Interactions 101 | if(state == "call-out"){ 102 | 103 | DTMFAudio.playCustom('dial'); 104 | 105 | }else if(state == "call-in"){ 106 | 107 | DTMFAudio.playCustom('ringback'); 108 | 109 | }else if(state == "ended"){ 110 | 111 | DTMFAudio.playCustom('howler'); 112 | 113 | setTimeout(function(){ 114 | DTMFAudio.stop(); 115 | },1000); 116 | 117 | }else{ 118 | DTMFAudio.stop(); 119 | } 120 | 121 | // Block keypad and show 122 | if(callActive){ 123 | 124 | // SHOW CONTROL OPTIONS (MUTE, HOLD, END, TRANSFER) IN NUMBER 125 | // NUMBERS SEND DTMF TONES 126 | } 127 | 128 | } 129 | 130 | return public; 131 | 132 | })(); -------------------------------------------------------------------------------- /app/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @singleton 3 | */ 4 | var AppClass = function () { 5 | 6 | EventEmitter.call(this); // Make App a event-emiter 7 | 8 | // Maximum number of event listeners (used to prevent memory leaks and dumb code) 9 | this.maxListeners = 20; 10 | 11 | this.version = "0.1.2"; // Please also change in chrome-extension/manifest 12 | 13 | this.init = function () { 14 | 15 | setupTabs(); 16 | 17 | var registered = localStorage.getItem("config.registered"); 18 | 19 | if(registered){ 20 | 21 | var account = localStorage.getItem("sip.account"); 22 | if (!account) { 23 | alert($loc.error_no_account); 24 | return false; 25 | } 26 | 27 | CallController.init(JSON.parse(account), onCallStateChange); 28 | 29 | DialPage.init($("#DialPage")); 30 | 31 | }else{ 32 | 33 | $("[data-tab='ConfigPage']").click(); 34 | 35 | } 36 | 37 | // Iinit wave form visualizer 38 | AudioVisualizer.init($('#phone-waveform')[0],$('#remoteAudio')[0]); 39 | // AudioVisualizer.init($('#phone-waveform')[0]); 40 | // AudioVisualizer.start(); 41 | 42 | 43 | // Show dial after configuration 44 | App.on('config::registered', function () { 45 | DialPage.init($("#DialPage")); 46 | CallController.setListener(onCallStateChange); 47 | 48 | $("[data-tab='DialPage']").removeAttr('disabled'); 49 | $("[data-tab='DialPage']").click(); 50 | }); 51 | 52 | App.on('call::state_change', function (state, e) { 53 | 54 | var $btnCall = $("#btnCall"); 55 | 56 | // =============================== 57 | // Footer / Call control 58 | // =============================== 59 | 60 | // btnCall state 61 | if(state == "call-out" || state == "connecting" || state == "connecting"){ 62 | $btnCall.addClass("is-loading"); 63 | }else if(state == "disconnected"){ 64 | $btnCall.removeClass("is-loading"); 65 | }else{ 66 | $btnCall.removeClass("is-loading"); 67 | } 68 | 69 | // Connection status ICON 70 | if(state == "disconnected"){ 71 | $btnCall.find(".fa").attr('class','fa fa-chain-broken'); 72 | $btnCall.attr("disabled", "disabled"); 73 | }else{ 74 | $btnCall.find(".fa").attr('class','fa fa-phone'); 75 | $btnCall.removeAttr("disabled"); 76 | } 77 | 78 | // =============================== 79 | // Overlay Status control 80 | // =============================== 81 | 82 | if (state == "call-out") { 83 | 84 | $("#overlay").addClass("active call-out"); 85 | 86 | } else if (state == "call-in") { 87 | 88 | $("#overlay").addClass("active call-in"); 89 | $("#overlay .subtitle").text(e.remoteIdentity.displayName); 90 | 91 | } else { 92 | 93 | $("#overlay").removeClass("active call-in call-out config"); 94 | 95 | } 96 | 97 | // Wave 98 | if (state == "connected") { 99 | setTimeout(function(){ 100 | AudioVisualizer.start(); 101 | },1000); // wait for remote media stream 102 | }else{ 103 | AudioVisualizer.stop(); 104 | } 105 | 106 | }); 107 | 108 | 109 | // Remove overlay on click 110 | $("#overlay a.button").on('click', function () { 111 | $("#overlay").removeClass("active call-in call-out config"); 112 | }); 113 | 114 | $("#overlay a.cancel").on('click', function () { 115 | CallController.stop(); 116 | }); 117 | 118 | $("#overlay a.answer").on('click', function () { 119 | CallController.answer(); 120 | }); 121 | } 122 | 123 | function onCallStateChange(state, e){ 124 | // Broadcast event 125 | App.emit('call::state_change', state, e); 126 | } 127 | 128 | /** 129 | * Control Pages / "Routes" 130 | */ 131 | function setupTabs(){ 132 | 133 | $(".tabs a").click(function(){ 134 | 135 | var $this = $(this); 136 | 137 | if($this.is(":disabled") || $this.attr('disabled')) return; 138 | 139 | $(".tabs li").removeClass('is-active'); 140 | 141 | $(".tab-content").hide(); // hideall 142 | 143 | 144 | 145 | var tab = $this.data('tab'); 146 | var $tab = $("#"+tab); 147 | $this.parent().addClass('is-active'); 148 | 149 | if($tab.data("loaded")){ 150 | $tab.show(); 151 | eval(tab+".show();"); // Dynamic call show 152 | }else{ 153 | $tab.load("pages/"+tab+".html",function() { 154 | $tab.show(); 155 | $tab.data("loaded", true); 156 | 157 | eval(tab+".init($tab);"); // Dynamic call init 158 | eval(tab+".show();"); // Dynamic call show 159 | 160 | // Load translation ($loc) 161 | $("[data-localize]", $tab).localize("locales/app"); 162 | }); 163 | } 164 | 165 | }); 166 | } 167 | 168 | }; 169 | 170 | // Extends EventEmitter (event-drive system) 171 | AppClass.prototype = Object.create(EventEmitter.prototype); 172 | AppClass.prototype.constructor = AppClass; 173 | var App = new AppClass(); 174 | 175 | $(function () { 176 | 177 | // Load translation ($loc) 178 | $("[data-localize]").localize("locales/app", { 179 | skipLanguage: "en", 180 | callback: function (data, fntranslate) { 181 | window.$loc = data; // global scope 182 | fntranslate(data); 183 | 184 | App.init(); 185 | } 186 | }); 187 | 188 | }); -------------------------------------------------------------------------------- /app/utils/dtmf.js: -------------------------------------------------------------------------------- 1 | /** 2 | * DTMF Tone generator based on: http://mamclain.com/?page=RND_SOFTWARE_DTMF_WEB_APP 3 | * CHANGES: 4 | * - 06/09/2017 - Small refactoring in structure and removal of duplicate codes 5 | * @author Ricardo JL Rufino 6 | * @singleton 7 | */ 8 | var DTMFAudio = (function () { 9 | 10 | this.const_DTMF_row_frequency = [1209, 1336, 1477, 1633]; 11 | this.const_DTMF_col_frequency = [697, 770, 852, 941]; 12 | this.const_DTMF_key = ["1", "2", "3", "A", "4", "5", "6", "B", "7", "8", "9", "C", "*", "0", "#", "D"]; 13 | this.const_audio_sample_rate = 44100; 14 | this.const_sine_samples = 44100; 15 | this.volume = 0.1; 16 | 17 | this.audioCtx = null; 18 | this.volCtl = null; 19 | this.var_DTMF_buffer = new Object(); 20 | this.var_DTMF_mix_list = new Object(); 21 | this.var_Precise_Tone_Plan_buffer = new Object(); 22 | this.var_source = null; 23 | 24 | this.var_dial_isdialing = false; 25 | this.var_dial_interval = null; 26 | this.var_dial_time_rate = 40; 27 | this.var_dial_message = ""; 28 | this.var_dial_message_index = 0; 29 | this.var_timeout_function = null; 30 | 31 | this.init = function () { 32 | try { 33 | window.AudioContext = window.AudioContext || window.webkitAudioContext; 34 | this.audioCtx = new AudioContext(); 35 | this.volCtl = this.audioCtx.createGain(); 36 | this.volCtl.gain.value = this.volume; 37 | this.volCtl.connect(this.audioCtx.destination) 38 | } 39 | catch (e) { 40 | alert('Web Audio API is not supported in this browser, We recommend You Download a Copy of Mozilla Firefox or Google Chrome!'); 41 | } 42 | this.PopulateDTMFBuffer(); 43 | this.PopulatePreciseTonePlan(); 44 | } 45 | 46 | this.MakeDTMFSineBuffer = function (frequencyA, frequencyB) { 47 | var buffer = this.audioCtx.createBuffer(1, this.const_sine_samples, this.const_audio_sample_rate); 48 | var channel = buffer.getChannelData(0); 49 | for (i = 0; i < this.const_sine_samples; ++i) { 50 | channel[i] = Math.sin((frequencyA * 2 * Math.PI * i) / this.const_audio_sample_rate) + Math.sin((frequencyB * 2 * Math.PI * i) / this.const_audio_sample_rate); 51 | } 52 | return buffer; 53 | } 54 | 55 | this.PopulatePreciseTonePlan = function () { 56 | 57 | // Dial Tone 58 | 59 | var aux_samples = this.const_audio_sample_rate * 6; 60 | var buffer = this.audioCtx.createBuffer(1, aux_samples, this.const_audio_sample_rate); 61 | var channel = buffer.getChannelData(0); 62 | var buffer_index = 0; 63 | var frequencyA = 350; 64 | var frequencyB = 440; 65 | 66 | var fade_array = new Array(); 67 | var delay_as_sample = Math.floor(this.const_audio_sample_rate * .7); // 2 68 | var fade_if_value = Math.floor(delay_as_sample * .10); // 2 69 | 70 | var fillBuffer = function (){ 71 | 72 | for (i = 0; i < delay_as_sample; i++) { 73 | if (i <= fade_if_value) { 74 | fade_array[i] = (1.0 / fade_if_value) * i; 75 | } 76 | else if (i >= delay_as_sample - fade_if_value - 1) { 77 | fade_array[i] = (1.0 / fade_if_value) * (delay_as_sample - (i + 1)); 78 | } 79 | else { 80 | fade_array[i] = 1; 81 | } 82 | } 83 | 84 | for (i = 0; i < aux_samples; ++i) { 85 | if (i < delay_as_sample) { 86 | channel[i] = fade_array[i] * Math.sin((frequencyA * 2 * Math.PI * i) / this.const_audio_sample_rate) + fade_array[i] * Math.sin((frequencyB * 2 * Math.PI * i) / this.const_audio_sample_rate); 87 | } 88 | else { 89 | channel[i] = 0; 90 | } 91 | } 92 | 93 | }; 94 | 95 | fillBuffer.call(this); 96 | this.var_Precise_Tone_Plan_buffer["dial"] = buffer; 97 | 98 | // Ringback Tone 2x on 4x off 99 | aux_samples = this.const_audio_sample_rate * 6; 100 | buffer = this.audioCtx.createBuffer(1, aux_samples, this.const_audio_sample_rate); 101 | channel = buffer.getChannelData(0); 102 | buffer_index = 0; 103 | frequencyA = 440; 104 | frequencyB = 480; 105 | 106 | fade_array = new Array(); 107 | delay_as_sample = this.const_audio_sample_rate * 2; // 1 108 | fade_if_value = Math.floor(delay_as_sample * .10); // 1 109 | 110 | fillBuffer.call(this); 111 | this.var_Precise_Tone_Plan_buffer["ringback"] = buffer; 112 | 113 | 114 | // busy tone 115 | aux_samples = this.const_audio_sample_rate; 116 | buffer = this.audioCtx.createBuffer(1, aux_samples, this.const_audio_sample_rate); 117 | channel = buffer.getChannelData(0); 118 | buffer_index = 0; 119 | frequencyA = 480; 120 | frequencyB = 620; 121 | 122 | fade_array = new Array(); 123 | delay_as_sample = Math.floor(this.const_audio_sample_rate * .5); // 2 124 | fade_if_value = Math.floor(delay_as_sample * .10); // 2 125 | 126 | 127 | fillBuffer.call(this); 128 | this.var_Precise_Tone_Plan_buffer["busy"] = buffer; 129 | 130 | 131 | // reorder tone 132 | aux_samples = Math.floor(this.const_audio_sample_rate * .5); 133 | buffer = this.audioCtx.createBuffer(1, aux_samples, this.const_audio_sample_rate); 134 | channel = buffer.getChannelData(0); 135 | buffer_index = 0; 136 | frequencyA = 480; 137 | frequencyB = 620; 138 | 139 | fade_array = new Array(); 140 | delay_as_sample = Math.floor(aux_samples * .5); // 3 141 | fade_if_value = Math.floor(delay_as_sample * .10); // 3 142 | 143 | fillBuffer.call(this); 144 | this.var_Precise_Tone_Plan_buffer["reorder"] = buffer; 145 | 146 | // off-hook tone 147 | var aux_samples = Math.floor(this.const_audio_sample_rate * .2); 148 | var buffer = this.audioCtx.createBuffer(1, aux_samples, this.const_audio_sample_rate); 149 | var channel = buffer.getChannelData(0); 150 | var buffer_index = 0; 151 | var frequencyA = 1400; 152 | var frequencyB = 2060; 153 | var frequencyC = 2450; 154 | var frequencyD = 2600; 155 | 156 | var fade_array = new Array(); 157 | 158 | var delay_as_sample = Math.floor(aux_samples * .5); // 4 159 | 160 | var fade_if_value = Math.floor(delay_as_sample * .10); // 4 161 | 162 | for (i = 0; i < delay_as_sample; i++) { 163 | if (i <= fade_if_value) { 164 | fade_array[i] = (1.0 / fade_if_value) * i; 165 | } 166 | else if (i >= delay_as_sample - fade_if_value - 1) { 167 | fade_array[i] = (1.0 / fade_if_value) * (delay_as_sample - (i + 1)); 168 | } 169 | else { 170 | fade_array[i] = 1; 171 | } 172 | } 173 | 174 | for (i = 0; i < aux_samples; ++i) { 175 | if (i < delay_as_sample) { 176 | channel[i] = fade_array[i] * Math.sin((frequencyA * 2 * Math.PI * i) / this.const_audio_sample_rate) + fade_array[i] * Math.sin((frequencyB * 2 * Math.PI * i) / this.const_audio_sample_rate) + fade_array[i] * Math.sin((frequencyC * 2 * Math.PI * i) / this.const_audio_sample_rate) + fade_array[i] * Math.sin((frequencyD * 2 * Math.PI * i) / this.const_audio_sample_rate); 177 | } 178 | else { 179 | channel[i] = 0; 180 | } 181 | } 182 | 183 | this.var_Precise_Tone_Plan_buffer["howler"] = buffer; 184 | 185 | } 186 | 187 | this.PopulateDTMFBuffer = function () { 188 | buffer_index = 0 189 | for (lc = 0; lc < this.const_DTMF_col_frequency.length; lc++) { 190 | for (lr = 0; lr < this.const_DTMF_row_frequency.length; lr++) { 191 | hash_key = this.const_DTMF_key[buffer_index]; 192 | frequencyA = this.const_DTMF_row_frequency[lr]; 193 | frequencyB = this.const_DTMF_col_frequency[lc]; 194 | 195 | this.var_DTMF_buffer[hash_key] = this.MakeDTMFSineBuffer(frequencyA, frequencyB); 196 | this.var_DTMF_mix_list[hash_key] = new Array(frequencyA, frequencyB); 197 | buffer_index = buffer_index + 1; 198 | } 199 | } 200 | } 201 | 202 | function createBufferSource() { 203 | this.var_source = this.audioCtx.createBufferSource(); 204 | if (!this.var_source.start) { 205 | this.var_source.start = this.var_source.noteOn; 206 | } 207 | if (!this.var_source.stop) { 208 | this.var_source.stop = this.var_source.noteOff; 209 | } 210 | } 211 | 212 | this.generateDTMF = function (key) { 213 | this.stop(); 214 | createBufferSource.call(this); 215 | 216 | this.var_source.loop = true; 217 | //this.var_source.connect(this.audioCtx.destination); 218 | this.var_source.connect(this.volCtl); 219 | 220 | this.var_source.buffer = this.var_DTMF_buffer[key]; 221 | this.var_source.start(0); 222 | } 223 | 224 | this.stopDTMF = function () { 225 | if (this.var_source != null) { 226 | this.var_source.stop(0); 227 | } 228 | } 229 | 230 | this.stop = function () { 231 | this.stop_dial_interval(); 232 | if (this.var_source != null) { 233 | this.var_source.stop(0); 234 | this.var_source = null; 235 | } 236 | 237 | } 238 | 239 | this.stop_dial_interval = function () { 240 | if (this.var_dial_isdialing == true) { 241 | window.clearInterval(this.var_dial_interval); 242 | this.var_dial_interval = null; 243 | if (this.var_source != null) { 244 | this.var_source.stop(0); 245 | this.var_source = null; 246 | } 247 | this.var_dial_isdialing = false; 248 | } 249 | this.var_dial_message = ""; 250 | this.var_dial_message_index = 0; 251 | } 252 | 253 | this.play = function (key) { 254 | this.generateDTMF(key); 255 | _this = this; 256 | setTimeout(function () { 257 | _this.stopDTMF(); 258 | }, 100); 259 | } 260 | 261 | this.playCustom = function (key) { 262 | if (!(key in this.var_Precise_Tone_Plan_buffer)) { 263 | alert("Precise Tone has no : " + key); 264 | return; 265 | } 266 | 267 | this.stop(); 268 | createBufferSource.call(this); 269 | 270 | this.var_source.loop = true; 271 | // this.var_source.connect(this.audioCtx.destination); 272 | this.var_source.connect(this.volCtl); 273 | 274 | buffer = this.var_Precise_Tone_Plan_buffer[key]; 275 | 276 | this.var_source.buffer = buffer; 277 | this.var_source.start(0); 278 | } 279 | 280 | return this; 281 | }).call({}); // create singleton instance -------------------------------------------------------------------------------- /app/utils/waveform.js: -------------------------------------------------------------------------------- 1 | /** 2 | * AudioVisualizer (Work for local and Remote MediaStream) 3 | * Reference: https://mdn.github.io/voice-change-o-matic 4 | * @author Ricardo JL Rufino 5 | * @singleton 6 | */ 7 | var AudioVisualizer = (function () { 8 | 9 | var started = false; 10 | var drawVisual; // requestFrame 11 | var canvas; 12 | var remoteAudio; // if you use reote 13 | var analyser; 14 | 15 | /** 16 | * @param {*} _canva 17 | * @param {*} (Optional) _remoteAudio - For WebRCT visualization 18 | */ 19 | this.init = function (_canvas, _remoteAudio) { 20 | canvas = _canvas; 21 | remoteAudio = _remoteAudio; 22 | } 23 | 24 | this.start = function(){ 25 | 26 | if(!canvas) return; // not initialized 27 | 28 | // fork getUserMedia for multiple browser versions, for those 29 | // that need prefixes 30 | 31 | navigator.getUserMedia = (navigator.getUserMedia || 32 | navigator.webkitGetUserMedia || 33 | navigator.mozGetUserMedia || 34 | navigator.msGetUserMedia); 35 | 36 | 37 | audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 38 | var source; 39 | var stream; 40 | 41 | analyser = audioCtx.createAnalyser(); 42 | analyser.minDecibels = -90; 43 | analyser.maxDecibels = -10; 44 | analyser.smoothingTimeConstant = 0.85; 45 | 46 | var canvasCtx = canvas.getContext("2d"); 47 | 48 | var intendedWidth = canvas.clientWidth; 49 | var dataArray = null; 50 | var bufferLength = null; 51 | var WIDTH = canvas.width; 52 | var HEIGHT = canvas.height; 53 | 54 | if(remoteAudio){ 55 | console.log("Using provided mediaStream "); 56 | 57 | setTimeout(function(){ 58 | 59 | if(remoteAudio.srcObject){ 60 | source = audioCtx.createMediaStreamSource(remoteAudio.srcObject); 61 | source.connect(analyser); 62 | started = true; 63 | visualize(); 64 | }else{ 65 | console.error("provided mediaStream not active ! "); 66 | } 67 | 68 | },100); 69 | 70 | }else{ // Use Local Media 71 | 72 | console.log("Using local mediaStream "); 73 | 74 | if (navigator.getUserMedia) { 75 | console.log('getUserMedia supported.'); 76 | navigator.getUserMedia ( 77 | // constraints - only audio needed for this app 78 | { 79 | audio: true 80 | }, 81 | 82 | // Success callback 83 | function(stream) { 84 | source = audioCtx.createMediaStreamSource(stream); 85 | source.connect(analyser); 86 | started = true; 87 | visualize(); 88 | }, 89 | 90 | // Error callback 91 | function(err) { 92 | console.log('The following gUM error occured: ' + err); 93 | started = false; 94 | } 95 | ); 96 | } else { 97 | console.log('getUserMedia not supported on your browser!'); 98 | started = false; 99 | } 100 | } 101 | 102 | function visualize() { 103 | analyser.fftSize = 2048; 104 | bufferLength = analyser.fftSize; 105 | 106 | // dataArray = new Uint8Array(bufferLength); 107 | dataArray = new Float32Array(bufferLength); 108 | canvasCtx.clearRect(0, 0, WIDTH, HEIGHT); 109 | draw(); 110 | } 111 | 112 | 113 | var draw = function() { 114 | 115 | drawVisual = requestAnimationFrame(draw); 116 | 117 | //analyser.getByteTimeDomainData(dataArray); 118 | analyser.getFloatTimeDomainData(dataArray); 119 | 120 | canvasCtx.fillStyle = '#56585a'; 121 | canvasCtx.fillRect(0, 0, WIDTH, HEIGHT); 122 | 123 | canvasCtx.lineWidth = 2; 124 | canvasCtx.strokeStyle = '#23d160'; 125 | 126 | canvasCtx.beginPath(); 127 | 128 | var sliceWidth = WIDTH * 1.0 / bufferLength; 129 | var x = 0; 130 | 131 | for(var i = 0; i < bufferLength; i++) { 132 | 133 | var v = dataArray[i] * 150.0; 134 | var y = HEIGHT/2 + v; 135 | 136 | if(i === 0) { 137 | canvasCtx.moveTo(x, y); 138 | } else { 139 | canvasCtx.lineTo(x, y); 140 | } 141 | 142 | x += sliceWidth; 143 | } 144 | 145 | canvasCtx.lineTo(canvas.width, canvas.height/2); 146 | canvasCtx.stroke(); 147 | }; 148 | 149 | } 150 | 151 | this.stop = function(){ 152 | if(!canvas) return; 153 | if(started){ 154 | started = false; 155 | window.cancelAnimationFrame(drawVisual); 156 | var canvasCtx = canvas.getContext("2d"); 157 | canvasCtx.fillRect(0, 0, canvas.width, canvas.height); 158 | } 159 | } 160 | 161 | return this; 162 | 163 | }).call({}); // create singleton instance -------------------------------------------------------------------------------- /chrome-extension/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appDesc": { 3 | "message": "Extension for Asterisk/Freeswitch that automatically identifies phone numbers and allows you to make calls through the browser" 4 | }, 5 | "call_action": { 6 | "message": "Call to %s" 7 | } 8 | } -------------------------------------------------------------------------------- /chrome-extension/_locales/pt_BR/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appDesc": { 3 | "message": "Extensão para Asterisk/Freeswitch que identifica automaticamente números de telefone e permite realizar as ligações pelo navegador" 4 | }, 5 | "call_action": { 6 | "message": "Ligar para %s" 7 | } 8 | } -------------------------------------------------------------------------------- /chrome-extension/background.js: -------------------------------------------------------------------------------- 1 | 2 | var popup = null; 3 | var WEBPHONE_DEV = "http://localhost:5000/index.src.html"; 4 | var WEBPHONE_PROD = "https://ricardojlrufino.github.io/webphone-sip"; 5 | var WEBPHONE_URL = WEBPHONE_PROD; 6 | var ENABLE_LOG = (WEBPHONE_URL == WEBPHONE_DEV); 7 | 8 | 9 | chrome.browserAction.onClicked.addListener(function (tab) { 10 | createPopup(); 11 | }); 12 | 13 | chrome.contextMenus.create({ 14 | title: chrome.i18n.getMessage("call_action"), 15 | contexts:["selection"], 16 | onclick: function(info, tab) { 17 | onPhoneClick(info.selectionText); 18 | } 19 | }); 20 | 21 | chrome.runtime.onMessage.addListener( 22 | function(event, sender, sendResponse) { 23 | if( event.type === "WEBPHONE_ONCLICK" ) { 24 | onPhoneClick(event.tel); 25 | } 26 | } 27 | ); 28 | 29 | function onPhoneClick(phone){ 30 | createPopup(function(){ 31 | chrome.tabs.query({'windowId': popup}, function (result) { 32 | if(ENABLE_LOG) console.log("Inserting phone... " + popup); 33 | var sc = "document.getElementById('phoneNumber').value = '" +phone + "';"; 34 | chrome.tabs.executeScript(result[0].id, {"code": sc}); 35 | }); 36 | }); 37 | } 38 | 39 | function createPopup(callback){ 40 | 41 | if(popup == null){ 42 | var cfg = { 43 | url: WEBPHONE_URL, 44 | width: 348, 45 | height: 480, 46 | focused: true, 47 | type: "panel", 48 | state: "docked" 49 | }; 50 | 51 | chrome.windows.create(cfg, function createWindow(window) { 52 | popup = window.tabs[0].windowId; 53 | if(ENABLE_LOG) console.log("WebPhone Popup Created"); 54 | if(callback) callback(); 55 | }); 56 | }else{ 57 | chrome.windows.update(popup, {focused: true}); 58 | if(callback) callback(); 59 | } 60 | 61 | } 62 | 63 | chrome.windows.onRemoved.addListener(function (window) { 64 | popup = null; 65 | if(ENABLE_LOG) console.log("WebPhone Closed"); 66 | }); 67 | -------------------------------------------------------------------------------- /chrome-extension/content.js: -------------------------------------------------------------------------------- 1 | 2 | document.addEventListener('WEBPHONE_ONCLICK', (e/*: Object*/) => { 3 | console.log("Webphone clicked link : " + e.detail); 4 | chrome.runtime.sendMessage({type: 'WEBPHONE_ONCLICK', tel: e.detail}); 5 | }); 6 | 7 | var valid = document.getElementsByClassName('webphone_link'); 8 | if (valid.length > 0) { 9 | console.log('webphone already initialized'); 10 | } else { 11 | 12 | // hook (and maintain) all tel links 13 | // window.setInterval(() => { 14 | [].forEach.call(document.getElementsByTagName('A'), (tag/*: Object*/) => { 15 | if (typeof tag.href === 'string' && tag.href.toLowerCase().startsWith('tel:')) { 16 | tag.className += ' webphone_link'; 17 | tag.href = `javascript:document.dispatchEvent(new CustomEvent('WEBPHONE_ONCLICK', { 'detail': '${ tag.href.substr(4) }'}));` 18 | // tag.title = `(Call using Browser)${ tag.title ? (` ${tag.title}`) : ''}` 19 | } 20 | }) 21 | // }, 1000); 22 | 23 | } 24 | 25 | 26 | -------------------------------------------------------------------------------- /chrome-extension/img/icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricardojlrufino/webphone-sip/b984f5fb187ecbedfffd5629398bb911769f29f5/chrome-extension/img/icon_128.png -------------------------------------------------------------------------------- /chrome-extension/img/icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricardojlrufino/webphone-sip/b984f5fb187ecbedfffd5629398bb911769f29f5/chrome-extension/img/icon_16.png -------------------------------------------------------------------------------- /chrome-extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "WebPhone", 4 | "description" : "__MSG_appDesc__", 5 | "version": "0.1.2", 6 | "default_locale" : "en", 7 | "icons": { 8 | "128": "/img/icon_128.png" 9 | }, 10 | "browser_action": { 11 | "default_icon": "/img/icon_16.png" 12 | }, 13 | "background": { 14 | "scripts": ["background.js"] 15 | }, 16 | "content_scripts": [ 17 | { 18 | "matches": [ 19 | "" 20 | ], 21 | "js": [ 22 | "content.js" 23 | ] 24 | } 25 | ], 26 | "permissions": [ 27 | "tabs", 28 | "contextMenus", 29 | "notifications", 30 | "http://*/", 31 | "https://*/" 32 | ] 33 | } -------------------------------------------------------------------------------- /css/bulma.override.css: -------------------------------------------------------------------------------- 1 | 2 | header .tabs a{ 3 | color: white; 4 | } 5 | 6 | header .tabs li.is-active{ 7 | background-color: #23d160; 8 | color:white; 9 | } 10 | 11 | header .tabs li.is-active a{ 12 | color:white; 13 | border-bottom-color: #46f74b; 14 | } 15 | 16 | 17 | header .tabs a:hover{ 18 | color:rgb(129, 129, 129); 19 | border-bottom-color: #468bf7; 20 | } 21 | 22 | a[disabled] { 23 | cursor: not-allowed; 24 | } -------------------------------------------------------------------------------- /css/style.css: -------------------------------------------------------------------------------- 1 | html{ 2 | overflow-y: hidden; 3 | } 4 | 5 | /* relevant styles */ 6 | .app { 7 | position: absolute; 8 | width: 100%; 9 | height: 100%; 10 | 11 | display: -moz-flex; 12 | -moz-flex-direction: column; 13 | -moz-flex-wrap: nowrap; 14 | 15 | display: -webkit-flex; 16 | -webkit-flex-direction: column; 17 | -webkit-flex-wrap: nowrap; 18 | 19 | display: -ms-flex; 20 | -ms-flex-direction: column; 21 | -ms-flex-wrap: nowrap; 22 | 23 | display: flex; 24 | flex-direction: column; 25 | flex-wrap: nowrap; 26 | } 27 | 28 | .app header { 29 | background-color: #216c89; 30 | -moz-flex-shrink: 0; 31 | -webkit-flex-shrink: 0; 32 | -ms-flex-shrink: 0; 33 | flex-shrink: 0; 34 | color: white; 35 | } 36 | 37 | .app footer{ 38 | background-color: #21896A; 39 | padding: 10px; 40 | -moz-flex-shrink: 0; 41 | -webkit-flex-shrink: 0; 42 | -ms-flex-shrink: 0; 43 | flex-shrink: 0; 44 | } 45 | 46 | #content{ 47 | -moz-flex-grow: 1; 48 | -webkit-flex-grow: 1; 49 | -ms-flex-grow: 1; 50 | flex-grow: 1; 51 | overflow: auto; 52 | min-height: 2em; 53 | background-color: #56585a; 54 | padding: 7px; 55 | } 56 | 57 | #caller-digits{ 58 | background-color: #eff1ff; 59 | padding: 12px; 60 | max-width: 349px; 61 | margin: 0px auto; 62 | } 63 | 64 | #caller-digits div.call-row{ 65 | margin-bottom: 5px; 66 | } 67 | 68 | #caller-digits .button { 69 | font-size: 1.9rem; 70 | min-width: 60px; 71 | } 72 | 73 | 74 | #phoneStatus{ 75 | background-color: #209cee; 76 | text-transform: uppercase; 77 | color: white; 78 | } 79 | 80 | #phoneStatus.phoneStatus-registered, #phoneStatus.phoneStatus-connected{ 81 | background-color: #23d160 !important; 82 | } 83 | 84 | #phoneStatus.phoneStatus-ended{ 85 | background-color: #a57a24 !important; 86 | } 87 | 88 | #controls-call-active{ 89 | display: none; 90 | } 91 | 92 | footer.call-active #controls-call-inactive{ 93 | display: none; 94 | } 95 | 96 | footer.call-active #controls-call-active{ 97 | display: block; 98 | } 99 | 100 | 101 | /* ---------------- Overlay ---------------- */ 102 | 103 | 104 | #overlay{ 105 | position: fixed; 106 | width: 100%; 107 | height: 100%; 108 | top: 0; 109 | left: 0; 110 | z-index: 9000; 111 | background: rgba(0,0,0,0.9); 112 | } 113 | 114 | #overlay label{ 115 | width: 58px; 116 | height:58px; 117 | position: absolute; 118 | right: 20px; 119 | top: 20px; 120 | background: url('https://tympanus.net/Development/FullscreenOverlayStyles/img/cross.png'); 121 | z-index: 100; 122 | cursor:pointer; 123 | } 124 | 125 | #overlay .title, #overlay .subtitle{ 126 | color: white; 127 | } 128 | 129 | #overlay nav { 130 | text-align: center; 131 | position: relative; 132 | top: 50%; 133 | height: 60%; 134 | font-size: 54px; 135 | -webkit-transform: translateY(-50%); 136 | transform: translateY(-50%); 137 | } 138 | 139 | #overlay ul { 140 | list-style: none; 141 | padding: 0; 142 | margin: 0 auto; 143 | display: inline-block; 144 | height: 100%; 145 | position: relative; 146 | } 147 | 148 | #overlay ul li { 149 | display: block; 150 | height: 20%; 151 | height: calc(100% / 5); 152 | min-height: 54px; 153 | } 154 | 155 | #overlay ul li a { 156 | font-weight: 300; 157 | display: block; 158 | color: white; 159 | text-decoration:none; 160 | -webkit-transition: color 0.2s; 161 | transition: color 0.2s; 162 | } 163 | 164 | #overlay ul li a:hover, 165 | #overlay ul li a:focus { 166 | color: #849368; 167 | } 168 | 169 | #overlay{ 170 | opacity: 0; 171 | visibility: hidden; 172 | -webkit-transition: opacity 0.5s, visibility 0s 0.5s; 173 | transition: opacity 0.5s, visibility 0s 0.5s; 174 | } 175 | 176 | #overlay.active{ 177 | opacity: 1; 178 | visibility: visible; 179 | -webkit-transition: opacity 0.5s; 180 | transition: opacity 0.5s; 181 | } 182 | 183 | #overlay nav { 184 | -moz-perspective: 300px; 185 | } 186 | 187 | #overlay nav ul { 188 | opacity: 0.4; 189 | -webkit-transform: translateY(-25%) rotateX(35deg); 190 | transform: translateY(-25%) rotateX(35deg); 191 | -webkit-transition: -webkit-transform 0.5s, opacity 0.5s; 192 | transition: transform 0.5s, opacity 0.5s; 193 | } 194 | 195 | #overlay.active nav ul { 196 | opacity: 1; 197 | -webkit-transform: rotateX(0deg); 198 | transform: rotateX(0deg); 199 | } 200 | 201 | #overlay:not(.active) nav ul { 202 | -webkit-transform: translateY(25%) rotateX(-35deg); 203 | transform: translateY(25%) rotateX(-35deg); 204 | } 205 | 206 | /* ---------------- Overlay / STATES ---------------- */ 207 | 208 | #overlay nav { 209 | display: none; 210 | } 211 | 212 | #overlay.call-in #nav-call-in { 213 | display: block; 214 | } 215 | 216 | #overlay.call-out #nav-call-out { 217 | display: block; 218 | } 219 | 220 | /* ---------------- Call animation ---------------- */ 221 | 222 | .calling{ 223 | top: 17px; 224 | left: 65px; 225 | } 226 | 227 | .calling.ball-scale-multiple > div { 228 | background-color: rgb(104, 212, 104); 229 | } 230 | 231 | @-webkit-keyframes ball-scale-multiple { 232 | 0% { 233 | -webkit-transform: scale(0); 234 | transform: scale(0); 235 | opacity: 0; } 236 | 5% { 237 | opacity: 1; } 238 | 100% { 239 | -webkit-transform: scale(1); 240 | transform: scale(1); 241 | opacity: 0; } } 242 | 243 | @keyframes ball-scale-multiple { 244 | 0% { 245 | -webkit-transform: scale(0); 246 | transform: scale(0); 247 | opacity: 0; } 248 | 5% { 249 | opacity: 1; } 250 | 100% { 251 | -webkit-transform: scale(1); 252 | transform: scale(1); 253 | opacity: 0; } } 254 | 255 | .ball-scale-multiple { 256 | position: relative; 257 | -webkit-transform: translateY(-30px); 258 | transform: translateY(-30px); } 259 | .ball-scale-multiple > div:nth-child(2) { 260 | -webkit-animation-delay: -0.4s; 261 | animation-delay: -0.4s; } 262 | .ball-scale-multiple > div:nth-child(3) { 263 | -webkit-animation-delay: -0.2s; 264 | animation-delay: -0.2s; } 265 | .ball-scale-multiple > div { 266 | background-color: #fff; 267 | width: 15px; 268 | height: 15px; 269 | border-radius: 100%; 270 | margin: 2px; 271 | -webkit-animation-fill-mode: both; 272 | animation-fill-mode: both; 273 | position: absolute; 274 | left: -30px; 275 | top: 0px; 276 | opacity: 0; 277 | margin: 0; 278 | width: 60px; 279 | height: 60px; 280 | -webkit-animation: ball-scale-multiple 1s 0s linear infinite; 281 | animation: ball-scale-multiple 1s 0s linear infinite; } 282 | 283 | .ball-scale-multiple { 284 | position: relative; 285 | -webkit-transform: translateY(-30px); 286 | transform: translateY(-30px) 287 | } 288 | 289 | .ball-scale-multiple>div:nth-child(2) { 290 | -webkit-animation-delay: -.4s; 291 | animation-delay: -.4s 292 | } 293 | 294 | .ball-scale-multiple>div:nth-child(3) { 295 | -webkit-animation-delay: -.2s; 296 | animation-delay: -.2s 297 | } 298 | 299 | .ball-scale-multiple>div { 300 | position: absolute; 301 | left: -30px; 302 | top: 0; 303 | opacity: 0; 304 | margin: 0; 305 | width: 60px; 306 | height: 60px; 307 | -webkit-animation: ball-scale-multiple 1s 0s linear infinite; 308 | animation: ball-scale-multiple 1s 0s linear infinite 309 | } -------------------------------------------------------------------------------- /dist/js/main.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * DTMF Tone generator based on: http://mamclain.com/?page=RND_SOFTWARE_DTMF_WEB_APP 3 | * CHANGES: 4 | * - 06/09/2017 - Small refactoring in structure and removal of duplicate codes 5 | * @author Ricardo JL Rufino 6 | * @singleton 7 | */ 8 | var DTMFAudio = (function () { 9 | 10 | this.const_DTMF_row_frequency = [1209, 1336, 1477, 1633]; 11 | this.const_DTMF_col_frequency = [697, 770, 852, 941]; 12 | this.const_DTMF_key = ["1", "2", "3", "A", "4", "5", "6", "B", "7", "8", "9", "C", "*", "0", "#", "D"]; 13 | this.const_audio_sample_rate = 44100; 14 | this.const_sine_samples = 44100; 15 | this.volume = 0.1; 16 | 17 | this.audioCtx = null; 18 | this.volCtl = null; 19 | this.var_DTMF_buffer = new Object(); 20 | this.var_DTMF_mix_list = new Object(); 21 | this.var_Precise_Tone_Plan_buffer = new Object(); 22 | this.var_source = null; 23 | 24 | this.var_dial_isdialing = false; 25 | this.var_dial_interval = null; 26 | this.var_dial_time_rate = 40; 27 | this.var_dial_message = ""; 28 | this.var_dial_message_index = 0; 29 | this.var_timeout_function = null; 30 | 31 | this.init = function () { 32 | try { 33 | window.AudioContext = window.AudioContext || window.webkitAudioContext; 34 | this.audioCtx = new AudioContext(); 35 | this.volCtl = this.audioCtx.createGain(); 36 | this.volCtl.gain.value = this.volume; 37 | this.volCtl.connect(this.audioCtx.destination) 38 | } 39 | catch (e) { 40 | alert('Web Audio API is not supported in this browser, We recommend You Download a Copy of Mozilla Firefox or Google Chrome!'); 41 | } 42 | this.PopulateDTMFBuffer(); 43 | this.PopulatePreciseTonePlan(); 44 | } 45 | 46 | this.MakeDTMFSineBuffer = function (frequencyA, frequencyB) { 47 | var buffer = this.audioCtx.createBuffer(1, this.const_sine_samples, this.const_audio_sample_rate); 48 | var channel = buffer.getChannelData(0); 49 | for (i = 0; i < this.const_sine_samples; ++i) { 50 | channel[i] = Math.sin((frequencyA * 2 * Math.PI * i) / this.const_audio_sample_rate) + Math.sin((frequencyB * 2 * Math.PI * i) / this.const_audio_sample_rate); 51 | } 52 | return buffer; 53 | } 54 | 55 | this.PopulatePreciseTonePlan = function () { 56 | 57 | // Dial Tone 58 | 59 | var aux_samples = this.const_audio_sample_rate * 6; 60 | var buffer = this.audioCtx.createBuffer(1, aux_samples, this.const_audio_sample_rate); 61 | var channel = buffer.getChannelData(0); 62 | var buffer_index = 0; 63 | var frequencyA = 350; 64 | var frequencyB = 440; 65 | 66 | var fade_array = new Array(); 67 | var delay_as_sample = Math.floor(this.const_audio_sample_rate * .7); // 2 68 | var fade_if_value = Math.floor(delay_as_sample * .10); // 2 69 | 70 | var fillBuffer = function (){ 71 | 72 | for (i = 0; i < delay_as_sample; i++) { 73 | if (i <= fade_if_value) { 74 | fade_array[i] = (1.0 / fade_if_value) * i; 75 | } 76 | else if (i >= delay_as_sample - fade_if_value - 1) { 77 | fade_array[i] = (1.0 / fade_if_value) * (delay_as_sample - (i + 1)); 78 | } 79 | else { 80 | fade_array[i] = 1; 81 | } 82 | } 83 | 84 | for (i = 0; i < aux_samples; ++i) { 85 | if (i < delay_as_sample) { 86 | channel[i] = fade_array[i] * Math.sin((frequencyA * 2 * Math.PI * i) / this.const_audio_sample_rate) + fade_array[i] * Math.sin((frequencyB * 2 * Math.PI * i) / this.const_audio_sample_rate); 87 | } 88 | else { 89 | channel[i] = 0; 90 | } 91 | } 92 | 93 | }; 94 | 95 | fillBuffer.call(this); 96 | this.var_Precise_Tone_Plan_buffer["dial"] = buffer; 97 | 98 | // Ringback Tone 2x on 4x off 99 | aux_samples = this.const_audio_sample_rate * 6; 100 | buffer = this.audioCtx.createBuffer(1, aux_samples, this.const_audio_sample_rate); 101 | channel = buffer.getChannelData(0); 102 | buffer_index = 0; 103 | frequencyA = 440; 104 | frequencyB = 480; 105 | 106 | fade_array = new Array(); 107 | delay_as_sample = this.const_audio_sample_rate * 2; // 1 108 | fade_if_value = Math.floor(delay_as_sample * .10); // 1 109 | 110 | fillBuffer.call(this); 111 | this.var_Precise_Tone_Plan_buffer["ringback"] = buffer; 112 | 113 | 114 | // busy tone 115 | aux_samples = this.const_audio_sample_rate; 116 | buffer = this.audioCtx.createBuffer(1, aux_samples, this.const_audio_sample_rate); 117 | channel = buffer.getChannelData(0); 118 | buffer_index = 0; 119 | frequencyA = 480; 120 | frequencyB = 620; 121 | 122 | fade_array = new Array(); 123 | delay_as_sample = Math.floor(this.const_audio_sample_rate * .5); // 2 124 | fade_if_value = Math.floor(delay_as_sample * .10); // 2 125 | 126 | 127 | fillBuffer.call(this); 128 | this.var_Precise_Tone_Plan_buffer["busy"] = buffer; 129 | 130 | 131 | // reorder tone 132 | aux_samples = Math.floor(this.const_audio_sample_rate * .5); 133 | buffer = this.audioCtx.createBuffer(1, aux_samples, this.const_audio_sample_rate); 134 | channel = buffer.getChannelData(0); 135 | buffer_index = 0; 136 | frequencyA = 480; 137 | frequencyB = 620; 138 | 139 | fade_array = new Array(); 140 | delay_as_sample = Math.floor(aux_samples * .5); // 3 141 | fade_if_value = Math.floor(delay_as_sample * .10); // 3 142 | 143 | fillBuffer.call(this); 144 | this.var_Precise_Tone_Plan_buffer["reorder"] = buffer; 145 | 146 | // off-hook tone 147 | var aux_samples = Math.floor(this.const_audio_sample_rate * .2); 148 | var buffer = this.audioCtx.createBuffer(1, aux_samples, this.const_audio_sample_rate); 149 | var channel = buffer.getChannelData(0); 150 | var buffer_index = 0; 151 | var frequencyA = 1400; 152 | var frequencyB = 2060; 153 | var frequencyC = 2450; 154 | var frequencyD = 2600; 155 | 156 | var fade_array = new Array(); 157 | 158 | var delay_as_sample = Math.floor(aux_samples * .5); // 4 159 | 160 | var fade_if_value = Math.floor(delay_as_sample * .10); // 4 161 | 162 | for (i = 0; i < delay_as_sample; i++) { 163 | if (i <= fade_if_value) { 164 | fade_array[i] = (1.0 / fade_if_value) * i; 165 | } 166 | else if (i >= delay_as_sample - fade_if_value - 1) { 167 | fade_array[i] = (1.0 / fade_if_value) * (delay_as_sample - (i + 1)); 168 | } 169 | else { 170 | fade_array[i] = 1; 171 | } 172 | } 173 | 174 | for (i = 0; i < aux_samples; ++i) { 175 | if (i < delay_as_sample) { 176 | channel[i] = fade_array[i] * Math.sin((frequencyA * 2 * Math.PI * i) / this.const_audio_sample_rate) + fade_array[i] * Math.sin((frequencyB * 2 * Math.PI * i) / this.const_audio_sample_rate) + fade_array[i] * Math.sin((frequencyC * 2 * Math.PI * i) / this.const_audio_sample_rate) + fade_array[i] * Math.sin((frequencyD * 2 * Math.PI * i) / this.const_audio_sample_rate); 177 | } 178 | else { 179 | channel[i] = 0; 180 | } 181 | } 182 | 183 | this.var_Precise_Tone_Plan_buffer["howler"] = buffer; 184 | 185 | } 186 | 187 | this.PopulateDTMFBuffer = function () { 188 | buffer_index = 0 189 | for (lc = 0; lc < this.const_DTMF_col_frequency.length; lc++) { 190 | for (lr = 0; lr < this.const_DTMF_row_frequency.length; lr++) { 191 | hash_key = this.const_DTMF_key[buffer_index]; 192 | frequencyA = this.const_DTMF_row_frequency[lr]; 193 | frequencyB = this.const_DTMF_col_frequency[lc]; 194 | 195 | this.var_DTMF_buffer[hash_key] = this.MakeDTMFSineBuffer(frequencyA, frequencyB); 196 | this.var_DTMF_mix_list[hash_key] = new Array(frequencyA, frequencyB); 197 | buffer_index = buffer_index + 1; 198 | } 199 | } 200 | } 201 | 202 | function createBufferSource() { 203 | this.var_source = this.audioCtx.createBufferSource(); 204 | if (!this.var_source.start) { 205 | this.var_source.start = this.var_source.noteOn; 206 | } 207 | if (!this.var_source.stop) { 208 | this.var_source.stop = this.var_source.noteOff; 209 | } 210 | } 211 | 212 | this.generateDTMF = function (key) { 213 | this.stop(); 214 | createBufferSource.call(this); 215 | 216 | this.var_source.loop = true; 217 | //this.var_source.connect(this.audioCtx.destination); 218 | this.var_source.connect(this.volCtl); 219 | 220 | this.var_source.buffer = this.var_DTMF_buffer[key]; 221 | this.var_source.start(0); 222 | } 223 | 224 | this.stopDTMF = function () { 225 | if (this.var_source != null) { 226 | this.var_source.stop(0); 227 | } 228 | } 229 | 230 | this.stop = function () { 231 | this.stop_dial_interval(); 232 | if (this.var_source != null) { 233 | this.var_source.stop(0); 234 | this.var_source = null; 235 | } 236 | 237 | } 238 | 239 | this.stop_dial_interval = function () { 240 | if (this.var_dial_isdialing == true) { 241 | window.clearInterval(this.var_dial_interval); 242 | this.var_dial_interval = null; 243 | if (this.var_source != null) { 244 | this.var_source.stop(0); 245 | this.var_source = null; 246 | } 247 | this.var_dial_isdialing = false; 248 | } 249 | this.var_dial_message = ""; 250 | this.var_dial_message_index = 0; 251 | } 252 | 253 | this.play = function (key) { 254 | this.generateDTMF(key); 255 | _this = this; 256 | setTimeout(function () { 257 | _this.stopDTMF(); 258 | }, 100); 259 | } 260 | 261 | this.playCustom = function (key) { 262 | if (!(key in this.var_Precise_Tone_Plan_buffer)) { 263 | alert("Precise Tone has no : " + key); 264 | return; 265 | } 266 | 267 | this.stop(); 268 | createBufferSource.call(this); 269 | 270 | this.var_source.loop = true; 271 | // this.var_source.connect(this.audioCtx.destination); 272 | this.var_source.connect(this.volCtl); 273 | 274 | buffer = this.var_Precise_Tone_Plan_buffer[key]; 275 | 276 | this.var_source.buffer = buffer; 277 | this.var_source.start(0); 278 | } 279 | 280 | return this; 281 | }).call({}); // create singleton instance 282 | /* ---------- */ 283 | /** 284 | * AudioVisualizer (Work for local and Remote MediaStream) 285 | * Reference: https://mdn.github.io/voice-change-o-matic 286 | * @author Ricardo JL Rufino 287 | * @singleton 288 | */ 289 | var AudioVisualizer = (function () { 290 | 291 | var started = false; 292 | var drawVisual; // requestFrame 293 | var canvas; 294 | var remoteAudio; // if you use reote 295 | var analyser; 296 | 297 | /** 298 | * @param {*} _canva 299 | * @param {*} (Optional) _remoteAudio - For WebRCT visualization 300 | */ 301 | this.init = function (_canvas, _remoteAudio) { 302 | canvas = _canvas; 303 | remoteAudio = _remoteAudio; 304 | } 305 | 306 | this.start = function(){ 307 | 308 | if(!canvas) return; // not initialized 309 | 310 | // fork getUserMedia for multiple browser versions, for those 311 | // that need prefixes 312 | 313 | navigator.getUserMedia = (navigator.getUserMedia || 314 | navigator.webkitGetUserMedia || 315 | navigator.mozGetUserMedia || 316 | navigator.msGetUserMedia); 317 | 318 | 319 | audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 320 | var source; 321 | var stream; 322 | 323 | analyser = audioCtx.createAnalyser(); 324 | analyser.minDecibels = -90; 325 | analyser.maxDecibels = -10; 326 | analyser.smoothingTimeConstant = 0.85; 327 | 328 | var canvasCtx = canvas.getContext("2d"); 329 | 330 | var intendedWidth = canvas.clientWidth; 331 | var dataArray = null; 332 | var bufferLength = null; 333 | var WIDTH = canvas.width; 334 | var HEIGHT = canvas.height; 335 | 336 | if(remoteAudio){ 337 | console.log("Using provided mediaStream "); 338 | 339 | setTimeout(function(){ 340 | 341 | if(remoteAudio.srcObject){ 342 | source = audioCtx.createMediaStreamSource(remoteAudio.srcObject); 343 | source.connect(analyser); 344 | started = true; 345 | visualize(); 346 | }else{ 347 | console.error("provided mediaStream not active ! "); 348 | } 349 | 350 | },100); 351 | 352 | }else{ // Use Local Media 353 | 354 | console.log("Using local mediaStream "); 355 | 356 | if (navigator.getUserMedia) { 357 | console.log('getUserMedia supported.'); 358 | navigator.getUserMedia ( 359 | // constraints - only audio needed for this app 360 | { 361 | audio: true 362 | }, 363 | 364 | // Success callback 365 | function(stream) { 366 | source = audioCtx.createMediaStreamSource(stream); 367 | source.connect(analyser); 368 | started = true; 369 | visualize(); 370 | }, 371 | 372 | // Error callback 373 | function(err) { 374 | console.log('The following gUM error occured: ' + err); 375 | started = false; 376 | } 377 | ); 378 | } else { 379 | console.log('getUserMedia not supported on your browser!'); 380 | started = false; 381 | } 382 | } 383 | 384 | function visualize() { 385 | analyser.fftSize = 2048; 386 | bufferLength = analyser.fftSize; 387 | 388 | // dataArray = new Uint8Array(bufferLength); 389 | dataArray = new Float32Array(bufferLength); 390 | canvasCtx.clearRect(0, 0, WIDTH, HEIGHT); 391 | draw(); 392 | } 393 | 394 | 395 | var draw = function() { 396 | 397 | drawVisual = requestAnimationFrame(draw); 398 | 399 | //analyser.getByteTimeDomainData(dataArray); 400 | analyser.getFloatTimeDomainData(dataArray); 401 | 402 | canvasCtx.fillStyle = '#56585a'; 403 | canvasCtx.fillRect(0, 0, WIDTH, HEIGHT); 404 | 405 | canvasCtx.lineWidth = 2; 406 | canvasCtx.strokeStyle = '#23d160'; 407 | 408 | canvasCtx.beginPath(); 409 | 410 | var sliceWidth = WIDTH * 1.0 / bufferLength; 411 | var x = 0; 412 | 413 | for(var i = 0; i < bufferLength; i++) { 414 | 415 | var v = dataArray[i] * 150.0; 416 | var y = HEIGHT/2 + v; 417 | 418 | if(i === 0) { 419 | canvasCtx.moveTo(x, y); 420 | } else { 421 | canvasCtx.lineTo(x, y); 422 | } 423 | 424 | x += sliceWidth; 425 | } 426 | 427 | canvasCtx.lineTo(canvas.width, canvas.height/2); 428 | canvasCtx.stroke(); 429 | }; 430 | 431 | } 432 | 433 | this.stop = function(){ 434 | if(!canvas) return; 435 | if(started){ 436 | started = false; 437 | window.cancelAnimationFrame(drawVisual); 438 | var canvasCtx = canvas.getContext("2d"); 439 | canvasCtx.fillRect(0, 0, canvas.width, canvas.height); 440 | } 441 | } 442 | 443 | return this; 444 | 445 | }).call({}); // create singleton instance 446 | /* ---------- */ 447 | 448 | /** 449 | * Controll calling processes and interact with SIP.js 450 | */ 451 | var CallController = (function () { 452 | 453 | var C = { 454 | STATUS_NULL: 0, 455 | STATUS_NEW: 1, 456 | STATUS_CONNECTING: 2, 457 | STATUS_CONNECTED: 3, 458 | STATUS_COMPLETED: 4 459 | }; 460 | 461 | var sipPhone; // SIP.js 462 | var callListener; // Send notifications to DialPage (call-in, call-out, etc...) 463 | 464 | var accountConfig; 465 | 466 | var public = {}; 467 | 468 | public.init = function (config, listener) { 469 | accountConfig = config; 470 | callListener = listener; 471 | initPhone(); 472 | }; 473 | 474 | public.setListener = function (listener) { 475 | callListener = listener; 476 | }; 477 | 478 | function initPhone(){ 479 | 480 | if(sipPhone){ 481 | alert("WARN: OLD call not finished !!"); 482 | return false; 483 | } 484 | 485 | // create audio tag if not exist 486 | var remoteAudio = document.getElementById("remoteAudio"); 487 | if(!remoteAudio){ 488 | var remoteAudio = document.createElement('audio'); 489 | remoteAudio.id = 'remoteAudio'; 490 | document.body.appendChild(remoteAudio); 491 | } 492 | 493 | var config = { 494 | uri: accountConfig.username + '@' + accountConfig.domain, 495 | wsServers: ['wss://' + accountConfig.proxy], // +':7443' 496 | authorizationUser: accountConfig.user, 497 | password: accountConfig.password, 498 | userAgentString : 'WebPhone/'+accountConfig.version 499 | }; 500 | 501 | try { 502 | sipPhone = new SIP.WebRTC.Simple({ 503 | media: { 504 | remote: { 505 | audio: remoteAudio 506 | } 507 | }, 508 | ua: config 509 | }); 510 | } catch (error) { 511 | console.error(error); 512 | alert("ERROR:" +error.message); 513 | throw error; 514 | } 515 | 516 | 517 | window.onunload = onunloadPage; 518 | 519 | sipPhone.on('connected', function(e){ 520 | callListener('connected', e); 521 | // e.sessionDescriptionHandler.peerConnection 522 | // if (pc.getRemoteStreams) { 523 | // remoteStream = pc.getRemoteStreams()[0]; 524 | // } 525 | }); 526 | sipPhone.on('registered', function(e){ 527 | callListener('registered', e); 528 | localStorage.setItem("sip.registered", true); 529 | }); 530 | sipPhone.on('unregistered', function(e){ 531 | callListener('unregistered', e); 532 | localStorage.setItem("sip.registered", false); 533 | }); 534 | sipPhone.on('registrationFailed', function(e){ 535 | callListener('registrationFailed', e); 536 | localStorage.setItem("sip.registered", false); 537 | }); 538 | sipPhone.on('ringing', function(e){ 539 | callListener('call-in', e); 540 | } ); 541 | sipPhone.on('disconnected', function(e){ 542 | callListener('disconnected', e); 543 | }); 544 | sipPhone.on('ended', function(e){callListener('ended', e); }); 545 | 546 | // WebSocket events 547 | sipPhone.ua.on('disconnected', function(e){ callListener('disconnected', e); }); 548 | sipPhone.ua.on('connecting', function(e){ callListener('connecting', e); }); 549 | 550 | callListener('connecting', sipPhone); 551 | } 552 | 553 | public.call = function(number){ 554 | 555 | var fixed = number.replace(/[^a-zA-Z0-9*#/.@]/g,'') 556 | sipPhone.call(fixed); 557 | 558 | callListener('call-out', number); 559 | } 560 | 561 | // Unregister the user agents and terminate all active sessions when the 562 | // window closes or when we navigate away from the page 563 | function onunloadPage(){ 564 | // if(sipPhone) sipPhone.stop(); 565 | } 566 | 567 | public.stop = function(){ 568 | if(sipPhone){ 569 | if(sipPhone.state == 1){ // new 570 | sipPhone.reject(); 571 | }else{ 572 | sipPhone.hangup(); 573 | } 574 | } 575 | } 576 | 577 | public.disconnect = function(){ 578 | if(sipPhone && sipPhone.state != C.STATUS_NULL){ 579 | console.log("removing old connection"); 580 | } 581 | delete sipPhone; 582 | sipPhone = null; 583 | if(callListener) callListener('disconnected'); 584 | } 585 | 586 | public.getState = function(){ 587 | if(sipPhone) return sipPhone.state; 588 | return null; 589 | } 590 | 591 | public.sendDTMF = function(key){ 592 | if(sipPhone) return sipPhone.sendDTMF(key); 593 | return null; 594 | } 595 | 596 | public.answer = function(){ 597 | if(sipPhone) return sipPhone.answer(); 598 | } 599 | 600 | public.setMute = function(value){ 601 | if(value) sipPhone.mute(); 602 | else sipPhone.unmute(); 603 | } 604 | 605 | public.setHold = function(value){ 606 | if(value) sipPhone.hold(); 607 | else sipPhone.unhold(); 608 | } 609 | 610 | // // 611 | // toogleHold: function () { 612 | // if(status){ 613 | // sipPhone.hold(); 614 | // } 615 | // }, 616 | 617 | return public; 618 | 619 | })(); 620 | /* ---------- */ 621 | var DialPage = (function () { 622 | 623 | var $el = null; 624 | var $btnCall, $phoneNumber; 625 | var callActive = false; 626 | 627 | var public = {}; 628 | 629 | public.init = function (el) { 630 | 631 | $el = el; 632 | 633 | App.on('call::state_change', onCallStateChange); 634 | 635 | DTMFAudio.init(); // init audio buffers 636 | 637 | $btnCall = $("#btnCall"); 638 | $phoneNumber = $("#phoneNumber"); 639 | $phoneNumber.keyup(function (e) { 640 | if (e.keyCode == 13) $btnCall.trigger("click"); 641 | if (e.keyCode == 38 || e.keyCode == 40) { // up-down 642 | $phoneNumber.val(localStorage.getItem('dial.lastNumber')); 643 | } 644 | }); 645 | 646 | 647 | $("#btnMute").on('click', function(){ 648 | var activate = ! ($(this).data('active') || false); 649 | CallController.setMute(activate); 650 | $(this).data('active', activate) 651 | if(activate){ 652 | $(this).removeClass("is-outlined"); 653 | }else{ 654 | $(this).addClass("is-outlined"); 655 | } 656 | }); 657 | 658 | $("#btnHold").on('click', function(){ 659 | var activate = ! ($(this).data('active') || false); 660 | CallController.setHold(activate); 661 | $(this).data('active', activate) 662 | if(activate){ 663 | $(this).removeClass("is-outlined"); 664 | }else{ 665 | $(this).addClass("is-outlined"); 666 | } 667 | }); 668 | 669 | $("#btnStopCall").on('click', function(){ 670 | CallController.stop(); 671 | }); 672 | 673 | $btnCall.on('click', function(){ 674 | 675 | if(callActive){ 676 | CallController.stop(); 677 | }else{ 678 | var number = $phoneNumber.val(); 679 | localStorage.setItem('dial.lastNumber', number); 680 | CallController.call(number); 681 | } 682 | 683 | }); 684 | 685 | // Play tones 686 | $("#caller-digits a").click(function(){ 687 | var text = $(this).text(); 688 | 689 | DTMFAudio.play(text); 690 | 691 | if(callActive) CallController.sendDTMF(text); 692 | else{ 693 | $phoneNumber.val($phoneNumber.val() + text); 694 | } 695 | 696 | }); 697 | 698 | }; 699 | 700 | public.show = function () { 701 | // none 702 | }; 703 | 704 | function onCallStateChange(state, e){ 705 | 706 | var $status = $("#phoneStatus"); 707 | $status.html($loc['status_'+state.replace("-", "_")]); 708 | $status.attr('class', 'tag phoneStatus-'+state); 709 | 710 | // General status 711 | if(state == "connected"){ 712 | callActive = true; 713 | $("footer").addClass('call-active'); 714 | $("#controls-call-active .button").data("active", false); // reset state 715 | }else{ 716 | callActive = false; 717 | $("footer").removeClass('call-active'); 718 | } 719 | 720 | // Sound Interactions 721 | if(state == "call-out"){ 722 | 723 | DTMFAudio.playCustom('dial'); 724 | 725 | }else if(state == "call-in"){ 726 | 727 | DTMFAudio.playCustom('ringback'); 728 | 729 | }else if(state == "ended"){ 730 | 731 | DTMFAudio.playCustom('howler'); 732 | 733 | setTimeout(function(){ 734 | DTMFAudio.stop(); 735 | },1000); 736 | 737 | }else{ 738 | DTMFAudio.stop(); 739 | } 740 | 741 | // Block keypad and show 742 | if(callActive){ 743 | 744 | // SHOW CONTROL OPTIONS (MUTE, HOLD, END, TRANSFER) IN NUMBER 745 | // NUMBERS SEND DTMF TONES 746 | } 747 | 748 | } 749 | 750 | return public; 751 | 752 | })(); 753 | /* ---------- */ 754 | 755 | var ConfigPage = (function () { 756 | 757 | var $el = null; 758 | 759 | var public = {}; 760 | 761 | var listenerStateChange = function(state){ 762 | 763 | // Broadcast event 764 | App.emit('call::state_change', state); 765 | 766 | if(state == 'registered'){ 767 | localStorage.setItem('config.registered',true); 768 | App.emit('config::registered'); 769 | } 770 | 771 | } 772 | 773 | public.init = function (el) { 774 | $el = el; 775 | 776 | $('form', $el).submit(function(e){ 777 | 778 | e.preventDefault(); 779 | 780 | var config = { 781 | username: $($el).find('[name="username"]').val(), 782 | domain: $($el).find('[name="domain"]').val(), 783 | proxy: $($el).find('[name="proxy"]').val(), 784 | password: $($el).find('[name="password"]').val() 785 | }; 786 | 787 | config.version = App.version; 788 | 789 | localStorage.setItem('sip.account', JSON.stringify(config)); 790 | 791 | 792 | CallController.disconnect(); 793 | CallController.init(config, listenerStateChange); 794 | 795 | }); 796 | 797 | 798 | $('input[type=file]', $el).on('change',loadFromFile); 799 | 800 | $('.btnCancel', $el).click(function(){ 801 | CallController.disconnect(); 802 | localStorage.removeItem('sip.account'); 803 | localStorage.removeItem('config.registered'); 804 | $('form', $el)[0].reset(); 805 | }); 806 | 807 | 808 | } 809 | 810 | public.show = function () { 811 | 812 | loadConfig(); 813 | 814 | } 815 | 816 | function loadConfig(){ 817 | var data = localStorage.getItem('sip.account'); 818 | 819 | if(data){ 820 | data = JSON.parse(data); 821 | $.each(data, function(key, value){ 822 | $('[name='+key+']', $el).val(value); 823 | }); 824 | } 825 | } 826 | 827 | function loadFromFile(e){ 828 | var oFReader = new FileReader(); 829 | oFReader.readAsBinaryString(e.target.files[0]); 830 | oFReader.onload = function (oFREvent) { 831 | localStorage.setItem('sip.account',oFREvent.target.result); 832 | loadConfig(); 833 | }; 834 | } 835 | 836 | return public; 837 | 838 | })(); 839 | /* ---------- */ 840 | /** 841 | * @singleton 842 | */ 843 | var AppClass = function () { 844 | 845 | EventEmitter.call(this); // Make App a event-emiter 846 | 847 | // Maximum number of event listeners (used to prevent memory leaks and dumb code) 848 | this.maxListeners = 20; 849 | 850 | this.version = "0.1.2"; // Please also change in chrome-extension/manifest 851 | 852 | this.init = function () { 853 | 854 | setupTabs(); 855 | 856 | var registered = localStorage.getItem("config.registered"); 857 | 858 | if(registered){ 859 | 860 | var account = localStorage.getItem("sip.account"); 861 | if (!account) { 862 | alert($loc.error_no_account); 863 | return false; 864 | } 865 | 866 | CallController.init(JSON.parse(account), onCallStateChange); 867 | 868 | DialPage.init($("#DialPage")); 869 | 870 | }else{ 871 | 872 | $("[data-tab='ConfigPage']").click(); 873 | 874 | } 875 | 876 | // Iinit wave form visualizer 877 | AudioVisualizer.init($('#phone-waveform')[0],$('#remoteAudio')[0]); 878 | // AudioVisualizer.init($('#phone-waveform')[0]); 879 | // AudioVisualizer.start(); 880 | 881 | 882 | // Show dial after configuration 883 | App.on('config::registered', function () { 884 | DialPage.init($("#DialPage")); 885 | CallController.setListener(onCallStateChange); 886 | 887 | $("[data-tab='DialPage']").removeAttr('disabled'); 888 | $("[data-tab='DialPage']").click(); 889 | }); 890 | 891 | App.on('call::state_change', function (state, e) { 892 | 893 | var $btnCall = $("#btnCall"); 894 | 895 | // =============================== 896 | // Footer / Call control 897 | // =============================== 898 | 899 | // btnCall state 900 | if(state == "call-out" || state == "connecting" || state == "connecting"){ 901 | $btnCall.addClass("is-loading"); 902 | }else if(state == "disconnected"){ 903 | $btnCall.removeClass("is-loading"); 904 | }else{ 905 | $btnCall.removeClass("is-loading"); 906 | } 907 | 908 | // Connection status ICON 909 | if(state == "disconnected"){ 910 | $btnCall.find(".fa").attr('class','fa fa-chain-broken'); 911 | $btnCall.attr("disabled", "disabled"); 912 | }else{ 913 | $btnCall.find(".fa").attr('class','fa fa-phone'); 914 | $btnCall.removeAttr("disabled"); 915 | } 916 | 917 | // =============================== 918 | // Overlay Status control 919 | // =============================== 920 | 921 | if (state == "call-out") { 922 | 923 | $("#overlay").addClass("active call-out"); 924 | 925 | } else if (state == "call-in") { 926 | 927 | $("#overlay").addClass("active call-in"); 928 | $("#overlay .subtitle").text(e.remoteIdentity.displayName); 929 | 930 | } else { 931 | 932 | $("#overlay").removeClass("active call-in call-out config"); 933 | 934 | } 935 | 936 | // Wave 937 | if (state == "connected") { 938 | setTimeout(function(){ 939 | AudioVisualizer.start(); 940 | },1000); // wait for remote media stream 941 | }else{ 942 | AudioVisualizer.stop(); 943 | } 944 | 945 | }); 946 | 947 | 948 | // Remove overlay on click 949 | $("#overlay a.button").on('click', function () { 950 | $("#overlay").removeClass("active call-in call-out config"); 951 | }); 952 | 953 | $("#overlay a.cancel").on('click', function () { 954 | CallController.stop(); 955 | }); 956 | 957 | $("#overlay a.answer").on('click', function () { 958 | CallController.answer(); 959 | }); 960 | } 961 | 962 | function onCallStateChange(state, e){ 963 | // Broadcast event 964 | App.emit('call::state_change', state, e); 965 | } 966 | 967 | /** 968 | * Control Pages / "Routes" 969 | */ 970 | function setupTabs(){ 971 | 972 | $(".tabs a").click(function(){ 973 | 974 | var $this = $(this); 975 | 976 | if($this.is(":disabled") || $this.attr('disabled')) return; 977 | 978 | $(".tabs li").removeClass('is-active'); 979 | 980 | $(".tab-content").hide(); // hideall 981 | 982 | 983 | 984 | var tab = $this.data('tab'); 985 | var $tab = $("#"+tab); 986 | $this.parent().addClass('is-active'); 987 | 988 | if($tab.data("loaded")){ 989 | $tab.show(); 990 | eval(tab+".show();"); // Dynamic call show 991 | }else{ 992 | $tab.load("pages/"+tab+".html",function() { 993 | $tab.show(); 994 | $tab.data("loaded", true); 995 | 996 | eval(tab+".init($tab);"); // Dynamic call init 997 | eval(tab+".show();"); // Dynamic call show 998 | 999 | // Load translation ($loc) 1000 | $("[data-localize]", $tab).localize("locales/app"); 1001 | }); 1002 | } 1003 | 1004 | }); 1005 | } 1006 | 1007 | }; 1008 | 1009 | // Extends EventEmitter (event-drive system) 1010 | AppClass.prototype = Object.create(EventEmitter.prototype); 1011 | AppClass.prototype.constructor = AppClass; 1012 | var App = new AppClass(); 1013 | 1014 | $(function () { 1015 | 1016 | // Load translation ($loc) 1017 | $("[data-localize]").localize("locales/app", { 1018 | skipLanguage: "en", 1019 | callback: function (data, fntranslate) { 1020 | window.$loc = data; // global scope 1021 | fntranslate(data); 1022 | 1023 | App.init(); 1024 | } 1025 | }); 1026 | 1027 | }); -------------------------------------------------------------------------------- /docs/preview-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricardojlrufino/webphone-sip/b984f5fb187ecbedfffd5629398bb911769f29f5/docs/preview-1.png -------------------------------------------------------------------------------- /docs/preview-1400x560.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricardojlrufino/webphone-sip/b984f5fb187ecbedfffd5629398bb911769f29f5/docs/preview-1400x560.png -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var config = { 4 | environment: 'development', // default 5 | src_folder : '/media/ricardo/Dados/TEMP/testes-voip/webphone-sip', 6 | 7 | development: function () { 8 | return this.environment === 'development'; 9 | }, 10 | production: function () { 11 | return this.environment === 'production'; 12 | } 13 | }; 14 | 15 | var gulp = require('gulp'), 16 | useref = require('gulp-useref'), 17 | gulpif = require('gulp-if'), 18 | rename = require("gulp-rename"), 19 | uglify = require('gulp-uglify'), 20 | minifyCss = require('gulp-clean-css'); 21 | 22 | gulp.task('copy-deps', function() { 23 | // return gulp.src('../opendevice-clients/opendevice-js/dist/js/opendevice.js').pipe(gulp.dest(config.src_folder + "/js")); 24 | }); 25 | 26 | gulp.task('build', function () { 27 | return gulp.src(config.src_folder+'/index.src.html') 28 | .pipe(rename('index.html')) 29 | .pipe(gulpif(config.production(), useref({ searchPath: config.src_folder , newLine : '\n/* ---------- */\n'}))) 30 | // .pipe(gulpif(config.production(), gulpif('*.js', uglify()))) 31 | .pipe(gulpif('*.css', minifyCss())) 32 | .pipe(gulp.dest(config.src_folder)); 33 | }); 34 | 35 | 36 | gulp.task('generate-service-worker', function(callback) { 37 | // var swPrecache = require('sw-precache'); 38 | // var swConfig = require('./sw-precache-config.js'); 39 | // if(config.development()){ 40 | // swConfig.handleFetch = false; 41 | // swConfig.staticFileGlobs = []; 42 | // } 43 | // swPrecache.write(config.src_folder+'/service-worker.js', swConfig, callback); 44 | }); 45 | 46 | gulp.task('build:production', ['set-production', 'build']); 47 | 48 | gulp.task('set-production', function () { 49 | config.environment = 'production'; 50 | }); 51 | 52 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | WebPhone 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 | 45 |
46 |
47 | 48 | 49 |
50 | 51 |
52 | 53 |
54 | 55 |
56 | 57 |
58 | 59 |
60 | 61 | 62 | 63 |
64 | 65 |
66 | 67 |
68 | 69 |
70 | 71 |
72 | 1 73 | 2 74 | 3 75 |
76 | 77 |
78 | 4 79 | 5 80 | 6 81 |
82 | 83 |
84 | 7 85 | 8 86 | 9 87 |
88 | 89 |
90 | * 91 | 0 92 | # 93 |
94 | 95 |
96 |
97 |
98 | 99 | 152 |
153 | 154 |
155 | 168 | 169 |
179 | 180 | 181 | 182 | 183 | 184 | -------------------------------------------------------------------------------- /index.src.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | WebPhone 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |
20 | 49 |
50 |
51 | 52 | 53 |
54 | 55 |
56 | 57 |
58 | 59 |
60 | 61 |
62 | 63 |
64 | 65 | 66 | 67 |
68 | 69 |
70 | 71 |
72 | 73 |
74 | 75 |
76 | 1 77 | 2 78 | 3 79 |
80 | 81 |
82 | 4 83 | 5 84 | 6 85 |
86 | 87 |
88 | 7 89 | 8 90 | 9 91 |
92 | 93 |
94 | * 95 | 0 96 | # 97 |
98 | 99 |
100 |
101 |
102 | 103 | 156 |
157 | 158 |
159 | 172 | 173 |
183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | -------------------------------------------------------------------------------- /locales/app-en-US.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricardojlrufino/webphone-sip/b984f5fb187ecbedfffd5629398bb911769f29f5/locales/app-en-US.json -------------------------------------------------------------------------------- /locales/app-en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title" : "WebPhone SIP", 3 | 4 | "tab_dial" : "Dial", 5 | "tab_history" : "History", 6 | "tab_config" : "Config.", 7 | 8 | "phoneNumber_field" : "Phone Number / URI", 9 | "username" : "User", 10 | "password" : "Password", 11 | "domain" : "Domain", 12 | "proxy" : "SIP Proxy", 13 | "load_from_file" : "File", 14 | 15 | "error_no_account" : "Account not configured !", 16 | 17 | "status_ended" : "Done", 18 | "status_connected" : "Connected", 19 | "status_registered" : "Registered", 20 | "status_call_in" : "Ringing", 21 | "status_call_out" : "Calling...", 22 | "status_unregistered" : "Off-Line / Logout", 23 | "status_registrationFailed" : "Auth Fail", 24 | 25 | 26 | "cal_in" : "Incoming Call", 27 | "cal_out" : "Calling...", 28 | "cancel" : "Cancel", 29 | "save" : "Save", 30 | "clean" : "Clean", 31 | "answer" : "Answer", 32 | "mute" : "Mute", 33 | "hold" : "Hold" 34 | 35 | } -------------------------------------------------------------------------------- /locales/app-pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "title" : "WebPhone SIP", 3 | 4 | "tab_dial" : "Discar", 5 | "tab_history" : "Histórico", 6 | "tab_config" : "Config.", 7 | 8 | "phoneNumber_field" : "Discar Número", 9 | "username" : "Usuário", 10 | "password" : "Senha", 11 | "domain" : "Domínio", 12 | "proxy" : "Proxy SIP", 13 | "load_from_file" : "Arquivo", 14 | 15 | "error_no_account" : "Account not configured !", 16 | 17 | "status_ended" : "Finalizada", 18 | "status_connected" : "Conectado", 19 | "status_registered" : "Ativo", 20 | "status_call_in" : "Nova Chamada", 21 | "status_call_out" : "Discando", 22 | "status_unregistered" : "Off-Line / Logout", 23 | "status_registrationFailed" : "Falha na Autenticação", 24 | 25 | 26 | "cal_in" : "Recebendo Chamada", 27 | "cal_out" : "Discando...", 28 | "cancel" : "Cancelar", 29 | "save" : "Salvar", 30 | "clean" : "Limpar", 31 | "answer" : "Atender", 32 | "mute" : "Mudo", 33 | "hold" : "Pausar" 34 | 35 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webphone-sip", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "python -m SimpleHTTPServer 5000", 8 | "build": "gulp build:production" 9 | }, 10 | "keywords": [], 11 | "author": "Ricardo JL Rufino", 12 | "license": "", 13 | "dependencies": { 14 | "bulma": "^0.6.0", 15 | "jquery": "^3.2.1", 16 | "jquery-localize": "^0.1.0", 17 | "sip.js": "^0.8.3", 18 | "wolfy87-eventemitter": "^5.2.3" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pages/ConfigPage.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 | 7 | 8 | 9 | 10 |
11 |
12 | 13 |
14 | 15 |
16 | 17 | 18 | 19 | 20 |
21 |
22 | 23 |
24 | 25 |
26 | 27 |
28 |
29 | 30 |
31 | 32 |
33 | 34 |
35 |
36 | 37 |
38 |
39 | 40 |
41 |
42 | 43 |
44 |
45 |
46 | 57 |
58 |
59 |
60 | 61 |
--------------------------------------------------------------------------------