Twilioで漫才をする？

## はじめに
2022年6月末でCodefontのライトプランでのAPI利用は廃止されました。
Coefontが面白くいろいろ遊んできましたので、最後にTwilioを絡めて成仏させました。

記載しているコードは、集大成ということもあり、過去にの記事にもある`Azure Function`などを使用したものになっています。

###### 過去記事
* [音声のCSS！Text to Speechを活用しよう](https://qiita.com/masaya3/items/90e5e143be2ea3e4f965)
* [CoeFontをC#で使ってみる](https://qiita.com/masaya3/items/2391c012d9c87c53680a)
* [TwilioをAzure Functions(C#)で動かす](https://qiita.com/masaya3/items/429c12c2c742b5fc07f5)

## Twilioで漫才をやってみる。
大好きな[赤い芸人さん](https://coefont.cloud/coefonts/34fa6f84-0664-4d93-9642-404cabc9edb8)のCoefontを活用させていただきます。
芸人さんには、芸をしてもらいたいということで、Twilioを使い、一人でしゃべくり漫才ができる作品にしました。

また、今回行った漫才は、こちらも個人的に大好きな[ミルクボーイ](https://profile.yoshimoto.co.jp/talent/detail?id=2843)さんのフォーマットをお借りしてます。
```:漫才ネタ（一部）
ボケ　　　「いきなりですけどね　うちのオカンがね　好きな電話APIがあるらしいんやけど」
ツッコミ　「あっ、そーなんや」
ボケ　　　「その名前をちょっと忘れたらしくてね」
ツッコミ　「電話APIの名前忘れてもうて、どうなってんねそれ」
```
ボケを**自分**が、ツッコミを**赤い芸人**が担当。
漫才は、ボケとツッコミの掛け合いがありますが、この掛け合いをTwilioだけでできるようにしました。

### 実現方法
処理の流れ位は以下の通り、
1. Twilioを使って声を録音する
1. 録音情報をテキスト化し、Coefontで赤い芸人の音声に変換する
1. 録音した音声を再生する
1. 再生した音声と漫才を行う

変換には時間がかかる為、リアルタイムではなく、再度電話時に録音した音声を再生させます。

## Twilioを使って声を録音する
#### 間を取る

掛け合いを行うには、ボケのセリフの時間分、無音が必要となります。
この間を作成するため、ツッコミの台詞毎に音声を作成し、セリフの前に無音時間を付与させ、連続で再生することで実現させます。

#### 無音を録音

Twilioの[録音< Recoad >](https://jp.twilio.com/docs/voice/twiml/record)において、無音の音声は、削除される設定になっています。
まずは、設定を変更して、無音情報を有効にします。

有効にするには、[`tirm`パラメータ](https://jp.twilio.com/docs/voice/twiml/record#attributes-trim)に`do-not-trim`を設定し、無音を有効にします。
さらに[`timeout`パラメータ](https://jp.twilio.com/docs/voice/twiml/record#attributes-timeout)の時間を変更し、無音時間の録音を長くします。
このパラメータを変更しないと5秒以上の無音が続くと録音が終了します。


これらをコードにすると以下のようになります。

```cs:録音時の処理
var response = new VoiceResponse();

string msg = "あなたの声を、あかい芸人の声に変換します。";
msg += "発信音の後に、メッセージを、30秒以内でお話しください。";
                    
response.Say(msg, language: "ja-jp", voice: voice_type);
response.Record(playBeep:true, maxLength:30, finishOnKey: "#", trim:"do-not-trim", action:api_url + "&mode=record", timeout:6);
            
return new ContentResult{Content = response.ToString(), ContentType = "application/xml"};
```

## 録音情報をテキスト化し、Coefontで赤い芸人の音声に変換する
録音が完了したら、音声をテキスト化し、テキストから赤い芸人の音声を作成します。

#### 音声をテキストに変換
まずは、録音した音声をテキスト化します。
テキスト化は、Azureの[Speech to Text REST API for short audio](https://docs.microsoft.com/ja-jp/azure/cognitive-services/speech-service/rest-speech-to-text-short)を使用しました。
※[Speech SDK](https://docs.microsoft.com/ja-jp/azure/cognitive-services/speech-service/speech-sdk)の利用が推奨されているようですが、短い時間の録音で手軽だったのでこちらを使用

このAPIを使用すると以下のような結果が取得できます。
結果には、テキスト(`DisplayText`)以外にも、喋り始めまでの時間(`Offset`)も取得可能です。
このOffset時間を漫才の間として使用します。

**注意**
Speech to Text REST API for short audio版のAPIの場合、5秒以上の無音はエラー(`InitialSilenceTimeout`)になるようです。
5秒以上を取得したい場合は、Speech SDK版を使用すればこの時間を設定できそうです。

[応答パラメータ](https://docs.microsoft.com/ja-jp/azure/cognitive-services/speech-service/rest-speech-to-text-short#response-parameters
)例）
```json:応答情報
{
  "RecognitionStatus": "Success",
  "DisplayText": "Remind me to buy 5 pencils.",
  "Offset": "1236645672289",
  "Duration": "1236645672289"
}
```



Record時の保存形式の初期設定はWavですので、Wavを想定した動作になっています。

```cs:変換時の処理
// 録音のバイナリデータを取得する
var response = await httpClient.GetAsync(req.Form["RecordingUrl"]);
byte[] record_byte = response.Content.ReadAsByteArrayAsync();

// 音声をテキスト化する (Speech to Text REST API)
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, speech_to_text_url);
request.Headers.Add("Ocp-Apim-Subscription-Key", subscriptionKey);

// 録音バイナリをContentsに設定
var httpContent = new ByteArrayContent(record_byte);

// ContentTypeをWavに設定 
httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(@"audio/wav");
request.Content = httpContent;

// APIの呼び出し
var response = await httpClient.SendAsync(request);
if(response.IsSuccessStatusCode){
  return JsonConvert.DeserializeObject<SpeechToTextResult>(resString);
}
```

#### Coefontに変換
テキスト化された情報を赤い芸人の声に変換します。
※詳しくは [「CoeFontをC#で使ってみる」](https://qiita.com/masaya3/items/2391c012d9c87c53680a)をご覧ください。
Coefone API仕様：https://docs.coefont.cloud/


```cs:変換処理
string unixTime = new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds().ToString();

// Coefontの指定や、変換の設定
var body = new Dictionary<string, object>()
{
  { "coefont", coe_takahashi_font},
  { "text"   , text},
  { "format" , "wav"}
};

//認証キーの作成          
var body_json = JsonConvert.SerializeObject(body);
var hmac_key = Encoding.UTF8.GetBytes(coe_secret);
var hmac_body = Encoding.UTF8.GetBytes(unixTime + body_json);
                
string signature = "";
using (HMACSHA256 hmac = new HMACSHA256(hmac_key)){
  var hash = hmac.ComputeHash(hmac_body, 0, hmac_body.Length);
  signature = BitConverter.ToString(hash);            
}
signature = signature.Replace("-", "").ToLower();

HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, coe_url);
request.Headers.Add("X-Coefont-Date", unixTime);
request.Headers.Add("X-Coefont-Content", signature);
request.Headers.Add("Authorization", coe_accessKey);

// 設定の指定
var httpContent = new StringContent(body_json, Encoding.UTF8, "application/json");  
request.Content = httpContent;
             
var response = await httpClient.SendAsync(request);
if(response.IsSuccessStatusCode){
  //保存処理
}
```

変換したデータは、ストレージアカウントに保存します。

```cs:保存処理
var blobServiceClient = new BlobServiceClient(connectionString);
var blobContainerClient = blobServiceClient.GetBlobContainerClient(blobContainerName );
await blobContainerClient.CreateIfNotExistsAsync(PublicAccessType.None);
var blobClient = blobContainerClient.GetBlobClient(tel + unixTime);

// データをアップロードする
await blobClient.UploadAsync(await response.Content.ReadAsStreamAsync());

// ContentTypeをWavに設定
var properties = blobClient.GetPropertiesAsync();
BlobHttpHeaders headers = new BlobHttpHeaders
{
  ContentType = "audio/wav"
};
await blobClient.SetHttpHeadersAsync(headers);  
```

#### 変換情報を保存
変換後のファイル名や、再生開始までの間の情報をCosmosDBに登録します。
※変換前のテキストや、録音元のURLなんかもテスト的に保存してます。
```cs:DB保存
Uri collectionUri = UriFactory.CreateDocumentCollectionUri(<<databaseId>>, <<collectionId>>);

string t_id = System.Guid.NewGuid().ToString();  
 
// ※音声認識時のOffsetは、100ナノ秒単位なので秒に変換
await documentsOut.AddAsync(new
{
  id = t_id,
  tel = call_tel,
  text = text_result.DisplayText,
  record = wav_url,
  audio = audio_id,
  wait = (int)(text_result.Offset / 10000000.0f)
   }
);
```


## 録音した音声を再生する

#### CosmosDBより情報取得
発信元の電話番号をキーとして、対象となる情報をCosmosDBより取得します。
```cs:情報検索
Uri uriFactory = UriFactory.CreateDocumentCollectionUri(<<databaseId>>, <<collectionId>>);
var list = client.CreateDocumentQuery<TakahashiInfo>(uriFactory)
                .Where(p => p.Tel.Equals(req.Form["From"]))
                .ToList();
```

#### 音声を再生する
取得した情報を元に、間(Wait)が設定されている場合は、音声毎の間に、[待ち時間 < Pause >](https://jp.twilio.com/docs/voice/twiml/pause])を指定し、
音声の[再生 < play >](https://jp.twilio.com/docs/voice/twiml/play)を登録します

```cs:音声の再生
var response = new VoiceResponse();

for(int i=0; i<list.Count; i++){
  if(list[i].Wait > 0) {
    // 間を登録
    response.Pause(length: list[i].Wait);
  }
  // 再生する音声ファイル名を登録
  response.Play(blob_url +  list[i].Audio);
}
response.Pause(length: 2);

return new ContentResult{Content = response.ToString(), ContentType = "application/xml"};   
```

再生されたら、あとは漫才をするだけです。

## 再生した音声と漫才を行う
こちらが実際のデモ動画（漫才のデモは2分40秒目から）
https://youtu.be/uxYsv9UJDic

## 補足
##### システム構成図
全体のシステム構成図は以下の通りです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189829/16287406-b03b-4e8d-4409-b8c983715985.png)

##### 操作フロー
削除とか、1個の音声データ再生とかにも対応してます！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189829/eca02dda-2bf6-c0d9-74c2-366e1efdc085.png)


## まとめ
結果としてそれっぽく出来上が多ったのではないかなと思います。
想定しないような使い方をすることで、普段では使わないようなパラメータや設定に出合えたのは良い勉強になりました。

無音に関しても、質問に対して悩んでいるから、応答までが長い・・など意外と業務に転用できたりするかもしれないなと思いました。

## 関連項目
* [電話だけで遊んでみる](https://qiita.com/masaya3/items/c05cf87c1a711e30c6c7)