3

We have built a web application. The application's core is to arrange the meetings/sessions on the web. So User A(Meeting co-ordinator) will arrange a meeting/session and all other participants B, C, D and etc will be joining in the meeting/session. So I have used Twilio group video call to achieve it.

I have the below use case. We want to do the voice pitch shifting of the User A's(Meeting co-ordinator) voice. So all other participants will be receiving the pitch-shifted voice in group video. We have analyzed the AWS Polly in Twilio but it doesn’t match with our use case.

So please advice is there any services in Twilio to achieve this scenario.
(or) will it be possible to interrupt Twilio group call and pass the pitch-shifted voice to other participants?

Sample Code Used

initAudio();

function initAudio() {

analyser1 = audioContext.createAnalyser();
analyser1.fftSize = 1024;
analyser2 = audioContext.createAnalyser();
analyser2.fftSize = 1024;

if (!navigator.getUserMedia)
    navigator.getUserMedia = navigator.webkitGetUserMedia || navigator.mozGetUserMedia;

if (!navigator.getUserMedia)
    return(alert("Error: getUserMedia not supported!"));

navigator.getUserMedia({ audio: true }, function(stream){
    gotStream(stream);
}, function(){ console.log('Error getting Microphone stream'); });

if ((typeof MediaStreamTrack === 'undefined')||(!MediaStreamTrack.getSources)){
    console.log("This browser does not support MediaStreamTrack, so doesn't support selecting sources.\n\nTry Chrome Canary.");
} else {
    MediaStreamTrack.getSources(gotSources);
}
}
function gotStream (stream) {
audioInput = audioContext.createMediaStreamSource(stream);
outputMix = audioContext.createGain();
dryGain = audioContext.createGain();
wetGain = audioContext.createGain();
effectInput = audioContext.createGain();
audioInput.connect(dryGain);
audioInput.connect(effectInput);
dryGain.connect(outputMix);
wetGain.connect(outputMix);
audioOutput = audioContext.createMediaStreamDestination();
outputMix.connect(audioOutput);
outputMix.connect(analyser2);
crossfade(1.0);
changeEffect();
}
    function crossfade (value) {
        var gain1 = Math.cos(value * 0.5 * Math.PI);
        var gain2 = Math.cos((1.0 - value) * 0.5 * Math.PI);

    dryGain.gain.value = gain1;
    wetGain.gain.value = gain2;
}

function createPitchShifter () {
    effect = new Jungle( audioContext );
    effect.output.connect( wetGain );
    effect.setPitchOffset(1);
    return effect.input;
}

function changeEffect () {
    if (currentEffectNode)
        currentEffectNode.disconnect();
if (effectInput)
    effectInput.disconnect();

var effect = 'pitch';

switch (effect) {
    case 'pitch':
        currentEffectNode = createPitchShifter();
        break;
}

audioInput.connect(currentEffectNode);
}

Facing the error while adding the Localaudiotrack to a room

var mediaStream = new Twilio.Video.LocalAudioTrack(audioOutput.stream);

room.localParticipant.publishTrack(mediaStream, {
    name: 'adminaudio'
});

ERROR: Uncaught (in promise) TypeError: Failed to execute 'addTrack' on 'MediaStream': parameter 1 is not of type 'MediaStreamTrack'.

Hub
  • 87
  • 1
  • 8
  • Hi Hub and welcome to Stackoverflow! Could you perhaps indicate _why_ you want to do the voice pitch? What is the end goal of all this? It is useful to include such information (by editing your question) as it can result in answerers suggesting another approach to reach the same goal. – Saaru Lindestøkke Sep 26 '18 at 08:18

2 Answers2

1

Twilio developer evangelist here.

There is nothing within Twilio itself that pitch shifts voices.

If you are building this in a browser, then you could use the Web Audio API to take the input from the user's microphone and pitch shift it, then provide the resultant audio stream to the Video API instead of the original mic stream.

philnash
  • 70,667
  • 10
  • 60
  • 88
  • 1
    How to provide the resultant audio stream to the Video API instead of the original mic stream? we have searched lot to provide it but can't able to find the solution – Hub Sep 27 '18 at 07:36
  • You would want to request access to the mic yourself using `getUserMedia`, manipulate the audio then [turn it into a `LocalAudioTrack` object](https://media.twiliocdn.com/sdk/js/video/releases/1.14.0/docs/LocalAudioTrack.html#LocalAudioTrack__anchor) and and [publish it to your `LocalParticipant`](https://media.twiliocdn.com/sdk/js/video/releases/1.14.0/docs/LocalParticipant.html#publishTrack). – philnash Sep 27 '18 at 07:41
  • This blog post shows how to publish an extra track (screen capture in this case), but gives you an idea of some of the steps: https://www.twilio.com/blog/2018/01/screen-sharing-twilio-video.html – philnash Sep 27 '18 at 07:42
  • Thanks will check and come back – Hub Sep 27 '18 at 08:03
  • I have manipulated the user mic audio, please let me know how to turn it into a LocalAudioTrack object. I'm facing error while converting it into LocalAudioTrack object – Hub Sep 27 '18 at 11:09
  • You create a `MediaStream` object with [`AudioContext#createMediaStreamDestination`](https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/createMediaStreamDestination). Then you initialize the `LocalAudioTrack` with the `mediaStream` (`new LocalAudioTrack(mediaStream)`). – philnash Sep 27 '18 at 11:14
  • Getting following error in `var mediaStream = new Twilio.Video.LocalAudioTrack(destination); ` - Uncaught (in promise) TypeError: Failed to execute 'addTrack' on 'MediaStream': parameter 1 is not of type 'MediaStreamTrack'. – Hub Sep 27 '18 at 11:59
  • `var destination = audioContext.createMediaStreamDestination(); var mediaStream = new Twilio.Video.LocalAudioTrack(destination); room.localParticipant.publishTrack(mediaStream, { name: 'adminaudio' });` This is the code I used – Hub Sep 27 '18 at 12:00
  • 1
    Try using `destination.stream` instead of just `destination` when initializing the `LocalAudioTrack`. – philnash Sep 27 '18 at 12:06
  • Still getting the same error `var mediaStream = new Twilio.Video.LocalAudioTrack(destination);` - Uncaught (in promise) TypeError: Failed to execute 'addTrack' on 'MediaStream': parameter 1 is not of type 'MediaStreamTrack'. – Hub Sep 27 '18 at 12:19
  • Have you connected the other web audio nodes to the media stream destination node? – philnash Sep 27 '18 at 12:20
  • 1
    Yes, please find the code below for pitch shifting `var audioContext = new AudioContext(); audioInput = audioContext.createMediaStreamSource(stream); outputMix = audioContext.createGain(); dryGain = audioContext.createGain(); wetGain = audioContext.createGain(); audioInput.connect(dryGain); dryGain.connect(outputMix); wetGain.connect(outputMix); outputMix.connect( audioContext.destination); outputMix.connect(analyser2); crossfade(1.0); changeEffect();` – Hub Sep 27 '18 at 12:23
  • 1
    Cool, so that code shows that you are connecting the mix to the default audioContext destination, which is usually the speakers. You should create the mediaStream destination instead and connect the outputMix to that, then use it in the `LocalAudioTrack`. – philnash Sep 27 '18 at 13:15
  • Can't get you can you please explain in detail – Hub Sep 27 '18 at 13:30
  • You currently have `outputMix.connect(audioContext.destination)` and really you want `destination = audioContext.createMediaStreamDestination); outputMix.connect(destination)`. See what I mean? – philnash Sep 27 '18 at 13:36
  • 1
    The destination is where you send the audio to. The default `audioContext.destination` is the speakers. You want to send the audio to a media stream that can then be turned into a `LocalAudioTrack`. So, you create a media stream destination with `MSdestination = audioContext.createMediaStreamDestination()`. You the connect your last audio node, the `outputMix` to that new destination: `outputMix.connect(MSdestination)`. Finally, you can use the destination to create a `LocalAudioTrack` like this: `lat = new LocalAudioTrack(MSdestination.stream)`. Does that make sense? – philnash Sep 27 '18 at 13:50
  • `var audioContext = new AudioContext(); audioInput = audioContext.createMediaStreamSource(stream); outputMix = audioContext.createGain(); dryGain = audioContext.createGain(); wetGain = audioContext.createGain(); audioInput.connect(dryGain); dryGain.connect(outputMix); wetGain.connect(outputMix); audioOutput = audioContext.createMediaStreamDestination();outputMix.connect(audioOutput);outputMix.connect(analyser2); crossfade(1.0); changeEffect();` I have changed as u suggested still same error `var mediaStream = new Twilio.Video.LocalAudioTrack(destination);` – Hub Sep 27 '18 at 13:52
  • Yep, but now you’ve called the destination `audioOutput`. So your last line should be `var mediaStream = new Twilio.Video.localAudioTrack(audioOutput.stream)`. – philnash Sep 27 '18 at 13:54
  • `var mediaStream = new Twilio.Video.LocalAudioTrack(audioOutput.stream); room.localParticipant.publishTrack(mediaStream, { name: 'adminaudio' });` as you said added the new destination now also facing the same error – Hub Sep 27 '18 at 13:55
  • Have you investigated any of the objects you have? Do you have an instance of a MediaStream there at the end and does it have any MediaStreamTracks? I’m out of time to help debug this today, if you’re still having problems, perhaps put together an example of all the code that I can run and see the error with myself? – philnash Sep 27 '18 at 13:58
  • Keep trying to fix it yourself too – philnash Sep 27 '18 at 14:04
  • I'm also trying all the possibilities to fix it :) – Hub Sep 27 '18 at 14:11
  • Have you seen my code is there any issue in implementation – Hub Sep 28 '18 at 06:21
  • 1
    Ok, my mistake, the `LocalAudioTrack` constructor takes a `MediaStreamTrack` as its first argument, not a `MediaStream` as we've been trying to pass, thus the errors. What you want is `new Twilio.Video.LocalAudioTrack(audioOutput.stream.getAudioTracks()[0])`. (getAudioTracks returns an array, which you might want to loop over, but since it's a mic stream that you've been working with, there should only be one track in the array.) – philnash Oct 01 '18 at 02:57
  • 1
    Added the pitch-shifted local audio track, can you please say how to receive this added audio in the another end – Hub Oct 01 '18 at 06:07
  • 1
    Publishing the track to the room, as you have shown in the updated code in the question, will make the stream available. You then need to ensure that for any track that is published, or newly published, to the room, that you attach it. What code do you have for receiving tracks and attaching them? – philnash Oct 01 '18 at 06:49
  • `room.on('participantConnected', participantConnected);participant.on('trackSubscribed', track => trackAdded(div, track));participant.tracks.forEach(track => trackAdded(div, track));div.appendChild(track.attach());` This is the code used for receiving the track and attaching them – Hub Oct 01 '18 at 06:54
  • So, are you finding that you aren't receiving the `trackSubscribed` event when you publish the track? Or does the remote participant not have any tracks when you loop through to attach them? – philnash Oct 01 '18 at 06:56
  • I'm getting both the live track and pitch shited tracks being played in the receiving end, how to find the track by name and add it alone in the receiving end – Hub Oct 01 '18 at 06:59
  • 1
    I would probably just stop asking for audio from the admin user in your initial setup. So, when you call [`Video.connect` pass `audio: false` as one of the options](https://media.twiliocdn.com/sdk/js/video/releases/1.14.0/docs/global.html#ConnectOptions). Then, you can add the altered audio track to the participant later. – philnash Oct 01 '18 at 07:03
  • `var connectOptions = { name: twilioRoomName, audio: false, logLevel: 'debug' }; if (previewTracks) { connectOptions.tracks = previewTracks; } initAudio(); Twilio.Video.connect(res.token, connectOptions).then(roomJoined, function(error) { console.log('Could not connect to Twilio: ' + error.message); });` I have added audio: false while getting connected, but still getting two voices? – Hub Oct 01 '18 at 07:12
  • Have you tried setting the [`tracks` option](https://media.twiliocdn.com/sdk/js/video/releases/1.14.0/docs/global.html#ConnectOptions) instead? – philnash Oct 01 '18 at 07:16
  • Great Thanks for your wonderful support, now it is working fine – Hub Oct 01 '18 at 07:27
  • Fantastic! Glad to hear it and good luck with the rest of the app. – philnash Oct 01 '18 at 07:30
0

the comments in the above answer are SO helpful! I've been researching this for a couple of weeks, posted to Twilio-video.js to no avail and finally just the right phrasing pulled this up on S.O!

but to summarize and to add what I've found to work since it's hard to follow all the 27 questions/comments/code excerpts:

when connecting to Twilio:

const room = await Video.connect(twilioToken, {
          name: roomName,
          tracks: localTracks,
          audio: false, // if you don't want to hear the normal voice at all, you can hide this and add the shifted track upon participant connections 
          video: true,
          logLevel: "debug",
        }).then((room) => {
          
          return room;
        });

upon a new (remote) participant connection:

        const stream = new MediaStream([audioTrack.mediaStreamTrack]);
        const audioContext = new AudioContext(); 
        const audioInput = audioContext.createMediaStreamSource(stream);

source.disconnect(audioOutput);
          console.log("using PitchShift.js");
          var pitchShift = PitchShift(audioContext);

          if (isFinite(pitchVal)) {
            pitchShift.transpose = pitchVal;
            console.log("gain is " + pitchVal);
          }
          pitchShift.wet.value = 1;
          pitchShift.dry.value = 0.5;

          try {
            audioOutput.stream.getAudioTracks()[0]?.applyConstraints({
              echoCancellation: true,
              noiseSuppression: true,
            });
          } catch (e) {
            console.log("tried to constrain audio track " + e);
          }

          var biquadFilter = audioContext.createBiquadFilter();
          // Create a compressor node
          var compressor = audioContext.createDynamicsCompressor();
          compressor.threshold.setValueAtTime(-50, audioContext.currentTime);
          compressor.knee.setValueAtTime(40, audioContext.currentTime);
          compressor.ratio.setValueAtTime(12, audioContext.currentTime);
          compressor.attack.setValueAtTime(0, audioContext.currentTime);
          compressor.release.setValueAtTime(0.25, audioContext.currentTime);
          //biquadFilter.type = "lowpass";
          if (isFinite(freqVal)) {
            biquadFilter.frequency.value = freqVal;
            console.log("gain is " + freqVal);
          }
          if (isFinite(gainVal)) {
            biquadFilter.gain.value = gainVal;
            console.log("gain is " + gainVal);
          }
          source.connect(compressor);
          compressor.connect(biquadFilter);
          biquadFilter.connect(pitchShift);
          pitchShift.connect(audioOutput);
   
        const localAudioWarpedTracks = new Video.LocalAudioTrack(audioOutput.stream.getAudioTracks()[0]);

        const audioElement2 = document.createElement("audio");
        document.getElementById("audio_div").appendChild(audioElement2);

        localAudioWarpedTracks.attach();

user3325025
  • 654
  • 7
  • 10