diff --git a/packages/common/src/webrtc/BaseCall.ts b/packages/common/src/webrtc/BaseCall.ts index 090a6f78..607cb851 100644 --- a/packages/common/src/webrtc/BaseCall.ts +++ b/packages/common/src/webrtc/BaseCall.ts @@ -266,10 +266,17 @@ export default abstract class BaseCall implements IWebRTCCall { audio: { deviceId: { exact: deviceId } }, }) const audioTrack = newStream.getAudioTracks()[0] + + // Preserve the enabled state from the old audio track + const { localStream } = this.options + const oldAudioTracks = localStream.getAudioTracks() + if (oldAudioTracks.length > 0) { + audioTrack.enabled = oldAudioTracks[0].enabled + } + sender.replaceTrack(audioTrack) this.options.micId = deviceId - const { localStream } = this.options localStream.getAudioTracks().forEach((t) => t.stop()) localStream.getVideoTracks().forEach((t) => newStream.addTrack(t)) this.options.localStream = newStream @@ -299,8 +306,15 @@ export default abstract class BaseCall implements IWebRTCCall { video: { deviceId: { exact: deviceId } }, }) const videoTrack = newStream.getVideoTracks()[0] - sender.replaceTrack(videoTrack) + + // Preserve the enabled state from the old video track const { localElement, localStream } = this.options + const oldVideoTracks = localStream.getVideoTracks() + if (oldVideoTracks.length > 0) { + videoTrack.enabled = oldVideoTracks[0].enabled + } + + sender.replaceTrack(videoTrack) attachMediaStream(localElement, newStream) this.options.camId = deviceId diff --git a/packages/js/examples/flask-mute-test/.gitignore b/packages/js/examples/flask-mute-test/.gitignore new file mode 100644 index 00000000..93a464bb --- /dev/null +++ b/packages/js/examples/flask-mute-test/.gitignore @@ -0,0 +1 @@ +static/ \ No newline at end of file diff --git a/packages/js/examples/flask-mute-test/README.md b/packages/js/examples/flask-mute-test/README.md new file mode 100644 index 00000000..9b232bb5 --- /dev/null +++ b/packages/js/examples/flask-mute-test/README.md @@ -0,0 +1,225 @@ +# SignalWire Audio Mute Fix Test - Flask Edition + +This Flask application provides an automated testing environment for verifying the audio mute state preservation fix in the SignalWire JavaScript SDK. It automatically generates JWT tokens and provides a comprehensive test interface. + +## ๐ŸŽฏ What This Tests + +This application tests the fix for a critical issue where: +- **Problem**: When a call was muted using `call.muteAudio()`, switching audio input devices with `call.setAudioInDevice()` would create a new audio track that was unmuted, losing the mute state. +- **Fix**: The SDK now preserves the `enabled` state from the old audio track when creating a new track during device switching. + +## ๐Ÿš€ Quick Start + +### Prerequisites + +1. **Python 3.7+** installed +2. **SignalWire Account** with: + - Space (e.g., `your-space.signalwire.com`) + - Project ID + - API Token +3. **Multiple Audio Devices** (built-in mic + USB headset, etc.) for testing + +### Setup + +1. **Clone and navigate to the example**: + ```bash + cd packages/js/examples/flask-mute-test + ``` + +2. **Install Python dependencies**: + ```bash + pip install -r requirements.txt + ``` + +3. **Set environment variables**: + ```bash + export SIGNALWIRE_SPACE="your-space.signalwire.com" + export SIGNALWIRE_PROJECT_ID="your-project-id-here" + export SIGNALWIRE_TOKEN="your-api-token-here" + ``` + + Or create a `.env` file: + ```bash + SIGNALWIRE_SPACE=your-space.signalwire.com + SIGNALWIRE_PROJECT_ID=your-project-id-here + SIGNALWIRE_TOKEN=your-api-token-here + ``` + +4. **Build and copy the SDK** (to use the local version with the mute fix): + ```bash + ./build-and-copy-sdk.sh + ``` + +5. **Run the Flask application**: + ```bash + python app.py + ``` + +6. **Open your browser**: + - Navigate to `http://localhost:3000` + - The JWT token will be automatically generated and displayed + +## ๐Ÿงช Running the Test + +### Automated Test + +1. **Connect**: Click "Connect to SignalWire" - JWT is auto-generated +2. **Make a Call**: Enter destination/source numbers and click "Start Call" +3. **Wait for Active**: Once call is active, the test button will be enabled +4. **Run Test**: Click "๐Ÿงช Run Mute Fix Test" to execute the automated test sequence + +### Manual Test + +You can also manually test using the controls that appear when a call is active: +- **Mute/Unmute Audio**: Toggle audio mute state +- **Device Selection**: Switch between available audio input devices +- **Real-time Feedback**: Watch the console log and button states + +## ๐Ÿ“‹ Test Sequence + +The automated test performs these steps: + +1. **Initial State Check**: Verifies audio starts unmuted +2. **Mute Audio**: Calls `muteAudio()` and verifies mute state +3. **๐ŸŽฏ Device Switch While Muted**: Switches audio device and verifies mute state is preserved (THE FIX) +4. **Unmute Audio**: Calls `unmuteAudio()` and verifies unmute state +5. **Device Switch While Unmuted**: Switches audio device and verifies unmute state is preserved + +### Expected Results + +โœ… **All tests should pass if the fix is working**: +- โœ… Initial State: Audio starts unmuted +- โœ… Mute Audio: Audio becomes muted +- โœ… **Mute Preserved After Device Switch**: Audio remains muted (this was the bug!) +- โœ… Unmute Audio: Audio becomes unmuted +- โœ… Unmute Preserved After Device Switch: Audio remains unmuted + +โŒ **If the fix is broken, you'll see**: +- โŒ Mute Preserved After Device Switch: Audio becomes unmuted (the bug returns) + +## ๐Ÿ”ง API Endpoints + +The Flask app provides these endpoints: + +- `GET /` - Main test interface +- `GET /api/token` - Generate JWT token +- `GET /api/config` - Get SignalWire configuration +- `GET /health` - Health check + +## ๐Ÿ“ Project Structure + +``` +flask-mute-test/ +โ”œโ”€โ”€ app.py # Flask application with JWT generation +โ”œโ”€โ”€ requirements.txt # Python dependencies +โ”œโ”€โ”€ templates/ +โ”‚ โ””โ”€โ”€ index.html # Test interface with automated testing +โ””โ”€โ”€ README.md # This file +``` + +## ๐Ÿ” Troubleshooting + +### Common Issues + +1. **"Missing required environment variables"** + - Ensure all three environment variables are set correctly + - Double-check your SignalWire credentials + +2. **"Need at least 2 audio devices to run test"** + - Connect a USB headset or external microphone + - The test requires multiple audio input devices to switch between + +3. **JWT Token Generation Fails** + - Verify your API token has the correct permissions + - Check that your project ID is correct + +4. **Call Fails to Connect** + - Ensure destination number is valid and reachable + - Check your SignalWire project has calling enabled + - Verify browser permissions for microphone access + +### Debug Information + +- **Console Log**: Real-time logging appears in the web interface +- **Browser Console**: Additional debug information in browser dev tools +- **Flask Logs**: Server-side logs in the terminal running the Flask app + +### Health Check + +Visit `http://localhost:3000/health` to verify: +- Flask app is running +- Environment variables are loaded +- SignalWire configuration is valid + +## ๐Ÿงฌ Technical Details + +### JWT Token Generation + +The Flask app generates JWT tokens using the SignalWire REST API endpoint: + +```python +# Request to SignalWire REST API +url = f"https://{SIGNALWIRE_SPACE}/api/relay/rest/jwt" +payload = { + "resource": f"browser-{uuid.uuid4()}", # Unique resource identifier + "expires_in": 60 # Token expires in 60 minutes +} + +# Authentication using project ID and token +auth = (SIGNALWIRE_PROJECT_ID, SIGNALWIRE_TOKEN) +response = requests.post(url, json=payload, auth=auth) +``` + +This approach uses SignalWire's official JWT generation endpoint rather than manually creating JWT tokens. + +### The Fix Implementation + +The fix is in `packages/common/src/webrtc/BaseCall.ts`: + +```typescript +async setAudioInDevice(deviceId: string): Promise { + // ... get new stream and track ... + + // ๐ŸŽฏ THE FIX: Preserve enabled state from old track + const { localStream } = this.options + const oldAudioTracks = localStream.getAudioTracks() + if (oldAudioTracks.length > 0) { + audioTrack.enabled = oldAudioTracks[0].enabled // Preserve mute state! + } + + // ... replace track and update stream ... +} +``` + +## ๐Ÿค Contributing + +To test changes to the SDK: + +1. **Make your changes** to the SDK source code in `packages/common/src/` + +2. **Build and copy the SDK**: + ```bash + ./build-and-copy-sdk.sh + ``` + +3. **Restart the Flask app** to serve the new SDK: + ```bash + python app.py + ``` + +4. **Refresh your browser** to load the updated SDK and **run the test** to verify your changes work + +### Development Workflow + +The Flask app is already configured to use the local SDK build. The workflow is: + +1. Edit SDK source code +2. Run `./build-and-copy-sdk.sh` +3. Restart Flask app +4. Test in browser + +This ensures you're always testing against your local changes rather than the CDN version. + +## ๐Ÿ“„ License + +This example is part of the SignalWire Node.js SDK and follows the same MIT license. \ No newline at end of file diff --git a/packages/js/examples/flask-mute-test/app.py b/packages/js/examples/flask-mute-test/app.py new file mode 100644 index 00000000..03951152 --- /dev/null +++ b/packages/js/examples/flask-mute-test/app.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +SignalWire Audio Mute Fix Test Server + +This Flask application provides: +1. JWT token generation for SignalWire Browser SDK +2. A test interface for verifying the audio mute state preservation fix +3. Automatic configuration using environment variables + +Usage: + export SIGNALWIRE_SPACE="your-space.signalwire.com" + export SIGNALWIRE_PROJECT_ID="your-project-id" + export SIGNALWIRE_TOKEN="your-api-token" + python app.py +""" + +import os +import time +import uuid +import hmac +import hashlib +import base64 +import json +import requests +from datetime import datetime, timedelta +from flask import Flask, render_template, jsonify, request +from flask_cors import CORS + +app = Flask(__name__) +CORS(app) + +# Configuration from environment variables +SIGNALWIRE_SPACE = os.getenv('SIGNALWIRE_SPACE') +SIGNALWIRE_PROJECT_ID = os.getenv('SIGNALWIRE_PROJECT_ID') +SIGNALWIRE_TOKEN = os.getenv('SIGNALWIRE_TOKEN') + +if not all([SIGNALWIRE_SPACE, SIGNALWIRE_PROJECT_ID, SIGNALWIRE_TOKEN]): + print("Error: Missing required environment variables:") + print("- SIGNALWIRE_SPACE") + print("- SIGNALWIRE_PROJECT_ID") + print("- SIGNALWIRE_TOKEN") + exit(1) + +def generate_jwt_token(): + """ + Generate a JWT token for SignalWire Browser SDK using the REST API + Based on: https://developer.signalwire.com/sdks/browser-sdk/v2/ + """ + + # Use SignalWire REST API to generate JWT token + url = f"https://{SIGNALWIRE_SPACE}/api/relay/rest/jwt" + + # Set up authentication + auth = (SIGNALWIRE_PROJECT_ID, SIGNALWIRE_TOKEN) + + # Request payload + payload = { + "resource": f"browser-{uuid.uuid4()}", # Unique resource identifier + "expires_in": 60 # Token expires in 60 minutes + } + + # Make the request + response = requests.post( + url, + json=payload, + auth=auth, + headers={'Content-Type': 'application/json'} + ) + + if response.status_code == 200: + data = response.json() + return data.get('jwt_token') + else: + raise Exception(f"Failed to generate JWT token: {response.status_code} - {response.text}") + +@app.route('/') +def index(): + """Serve the main test interface""" + return render_template('index.html', + space=SIGNALWIRE_SPACE, + project_id=SIGNALWIRE_PROJECT_ID) + +@app.route('/api/token', methods=['GET']) +def get_token(): + """Generate and return a new JWT token""" + try: + token = generate_jwt_token() + return jsonify({ + 'success': True, + 'token': token, + 'expires_in': 3600 # 60 minutes in seconds + }) + except Exception as e: + print(f"Error generating JWT token: {str(e)}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + +@app.route('/api/config', methods=['GET']) +def get_config(): + """Return configuration for the frontend""" + return jsonify({ + 'space': SIGNALWIRE_SPACE, + 'project_id': SIGNALWIRE_PROJECT_ID + }) + +@app.route('/health', methods=['GET']) +def health_check(): + """Health check endpoint""" + return jsonify({ + 'status': 'healthy', + 'timestamp': datetime.utcnow().isoformat(), + 'space': SIGNALWIRE_SPACE, + 'project_configured': bool(SIGNALWIRE_PROJECT_ID) + }) + +if __name__ == '__main__': + print(f"Starting SignalWire Mute Fix Test Server...") + print(f"Space: {SIGNALWIRE_SPACE}") + print(f"Project ID: {SIGNALWIRE_PROJECT_ID}") + print(f"Server will be available at: http://localhost:3000") + print(f"Test interface at: http://localhost:3000") + + app.run(debug=True, host='0.0.0.0', port=3000) \ No newline at end of file diff --git a/packages/js/examples/flask-mute-test/build-and-copy-sdk.sh b/packages/js/examples/flask-mute-test/build-and-copy-sdk.sh new file mode 100755 index 00000000..a5e15772 --- /dev/null +++ b/packages/js/examples/flask-mute-test/build-and-copy-sdk.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Build and Copy SDK Script +# This script builds the SignalWire SDK and copies it to the Flask app + +set -e + +echo "๐Ÿ”จ Building SignalWire SDK with mute fix..." + +# Navigate to the project root +cd ../../../.. + +# Build the JavaScript SDK +echo "๐Ÿ“ฆ Running npm run setup js..." +npm run setup js + +# Build development version (less minified) +echo "๐Ÿ“ฆ Building development version..." +cd packages/js +NODE_ENV=development npx webpack --mode development +cd ../.. + +# Copy the built SDK to the Flask app +echo "๐Ÿ“‹ Copying built SDK to Flask app..." +cp packages/js/dist/index.min.js packages/js/examples/flask-mute-test/static/signalwire.js + +echo "โœ… SDK built and copied successfully!" +echo "๐ŸŽฏ The Flask app will now use the local SDK with your mute fix." +echo "" +echo "๐Ÿ’ก To test your changes:" +echo " 1. Run this script after making SDK changes" +echo " 2. Restart the Flask app (python app.py)" +echo " 3. Refresh the browser to load the new SDK" \ No newline at end of file diff --git a/packages/js/examples/flask-mute-test/requirements.txt b/packages/js/examples/flask-mute-test/requirements.txt new file mode 100644 index 00000000..b3cafaeb --- /dev/null +++ b/packages/js/examples/flask-mute-test/requirements.txt @@ -0,0 +1,4 @@ +Flask==2.3.3 +Flask-CORS==4.0.0 +Werkzeug==2.3.7 +requests==2.31.0 \ No newline at end of file diff --git a/packages/js/examples/flask-mute-test/run.sh b/packages/js/examples/flask-mute-test/run.sh new file mode 100755 index 00000000..d7a18425 --- /dev/null +++ b/packages/js/examples/flask-mute-test/run.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# SignalWire Audio Mute Fix Test - Flask Edition +# Quick start script + +set -e + +echo "๐ŸŽค SignalWire Audio Mute Fix Test - Flask Edition" +echo "==================================================" + +# Check if Python is installed +if ! command -v python3 &> /dev/null; then + echo "โŒ Python 3 is required but not installed." + echo "Please install Python 3.7+ and try again." + exit 1 +fi + +# Check if pip is installed +if ! command -v pip3 &> /dev/null; then + echo "โŒ pip3 is required but not installed." + echo "Please install pip3 and try again." + exit 1 +fi + +# Check environment variables +if [ -z "$SIGNALWIRE_SPACE" ] || [ -z "$SIGNALWIRE_PROJECT_ID" ] || [ -z "$SIGNALWIRE_TOKEN" ]; then + echo "โŒ Missing required environment variables:" + echo "" + echo "Please set the following environment variables:" + echo "export SIGNALWIRE_SPACE=\"your-space.signalwire.com\"" + echo "export SIGNALWIRE_PROJECT_ID=\"your-project-id-here\"" + echo "export SIGNALWIRE_TOKEN=\"your-api-token-here\"" + echo "" + echo "Or create a .env file with these values." + exit 1 +fi + +# Install dependencies if requirements.txt exists and is newer than last install +if [ -f "requirements.txt" ]; then + if [ ! -f ".last_install" ] || [ "requirements.txt" -nt ".last_install" ]; then + echo "๐Ÿ“ฆ Installing Python dependencies..." + pip3 install -r requirements.txt + touch .last_install + else + echo "โœ… Dependencies already installed" + fi +fi + +echo "" +echo "๐Ÿš€ Starting Flask application..." +echo "Space: $SIGNALWIRE_SPACE" +echo "Project ID: $SIGNALWIRE_PROJECT_ID" +echo "" +echo "๐ŸŒ Open your browser to: http://localhost:3000" +echo "Press Ctrl+C to stop the server" +echo "" + +# Run the Flask app +python3 app.py \ No newline at end of file diff --git a/packages/js/examples/flask-mute-test/static/.keep b/packages/js/examples/flask-mute-test/static/.keep new file mode 100644 index 00000000..e69de29b diff --git a/packages/js/examples/flask-mute-test/templates/index.html b/packages/js/examples/flask-mute-test/templates/index.html new file mode 100644 index 00000000..3a16bfec --- /dev/null +++ b/packages/js/examples/flask-mute-test/templates/index.html @@ -0,0 +1,594 @@ + + + + SignalWire Audio Mute Fix Test + + + + + + + + + + + + + + + + +
+
+
+

๐ŸŽค SignalWire Audio Mute Fix Test

+

+ Automated test for verifying that audio mute state is preserved when switching input devices. +

+
+ Space: {{ space }}
+ Project ID: {{ project_id }}
+ JWT: Loading...
+ SDK: Local Build with Mute Fix +
+
+
+ +
+
+ +
+
+
Connection
+ + +
+ Status: Not Connected +
+
+
+ + +
+
+
Call Control
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ Call Status: None +
+
+
+ + +
+
+
Automated Test
+ +
+ Test not started +
+
+
+
+ +
+ +
+
+
Local Video
+ +
+
+
Remote Video
+ +
+
+ + + + + +
+
+
Test Results
+
+

No tests run yet. Click "Run Mute Fix Test" to start.

+
+
+
+ + +
+
+
Console Log
+
+
Waiting for connection...
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/packages/js/examples/flask-mute-test/test_jwt.py b/packages/js/examples/flask-mute-test/test_jwt.py new file mode 100644 index 00000000..17a0c0d8 --- /dev/null +++ b/packages/js/examples/flask-mute-test/test_jwt.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +Test script to verify JWT generation works correctly +""" + +import os +import sys +import requests +import uuid + +# Configuration from environment variables +SIGNALWIRE_SPACE = os.getenv('SIGNALWIRE_SPACE') +SIGNALWIRE_PROJECT_ID = os.getenv('SIGNALWIRE_PROJECT_ID') +SIGNALWIRE_TOKEN = os.getenv('SIGNALWIRE_TOKEN') + +def test_jwt_generation(): + """Test JWT token generation using SignalWire REST API""" + + print(f"Testing JWT generation...") + print(f"Space: {SIGNALWIRE_SPACE}") + print(f"Project ID: {SIGNALWIRE_PROJECT_ID}") + print(f"Token: {SIGNALWIRE_TOKEN[:10]}...") + + # Use SignalWire REST API to generate JWT token + url = f"https://{SIGNALWIRE_SPACE}/api/relay/rest/jwt" + + # Set up authentication + auth = (SIGNALWIRE_PROJECT_ID, SIGNALWIRE_TOKEN) + + # Request payload + payload = { + "resource": f"browser-{uuid.uuid4()}", # Unique resource identifier + "expires_in": 60 # Token expires in 60 minutes + } + + print(f"Making request to: {url}") + print(f"Payload: {payload}") + + # Make the request + try: + response = requests.post( + url, + json=payload, + auth=auth, + headers={'Content-Type': 'application/json'} + ) + + print(f"Response status: {response.status_code}") + print(f"Response headers: {dict(response.headers)}") + print(f"Response text: {response.text}") + + if response.status_code == 200: + data = response.json() + jwt_token = data.get('jwt_token') + print(f"โœ… JWT token generated successfully!") + print(f"Token: {jwt_token[:50]}...") + return jwt_token + else: + print(f"โŒ Failed to generate JWT token: {response.status_code} - {response.text}") + return None + + except Exception as e: + print(f"โŒ Exception during JWT generation: {str(e)}") + return None + +if __name__ == '__main__': + if not all([SIGNALWIRE_SPACE, SIGNALWIRE_PROJECT_ID, SIGNALWIRE_TOKEN]): + print("โŒ Missing required environment variables:") + print("- SIGNALWIRE_SPACE") + print("- SIGNALWIRE_PROJECT_ID") + print("- SIGNALWIRE_TOKEN") + sys.exit(1) + + test_jwt_generation() \ No newline at end of file