短動画プラットフォームにおけるテキスト読み上げ(TTS)機能の実装は、単なるAPI呼び出しにとどまらず、文字数による処理の振り分けや非同期ファイル管理、多音字の処理など、多岐にわたる技術的判断が必要です。本記事では、Javaを用いたTTSシステムの設計と、腾讯云(Tencent Cloud)のAPIを活用した具体的な実装コードについて解説します。
戦略パターンによる処理の振り分け
TTSの実装において、まず考慮すべきはテキストの長さです。腾讯云のAPIでは、短いテキスト(156字以内)を同期的に処理する「基本合成」と、長いテキスト(10万字以内)を非同期で処理する「長音声合成」が提供されています。これらを透過的に扱うため、戦略パターン(Strategy Pattern)を採用し、テキスト長に応じて適切なプロセッサを選択する設計としました。
以下は、短いテキスト用のプロセッサ実装例です。リクエストの文字数が閾値を下回る場合、このプロセッサが選択され、同期的に音声データを取得します。
@Component
@Slf4j
public class ShortTextSynthesisHandler implements AudioSynthesisStrategy {
private final Credential cloudCredential;
public ShortTextSynthesisHandler() {
this.cloudCredential = new Credential(
ConfigKeys.TENCENT_SECRET_ID,
ConfigKeys.TENCENT_SECRET_KEY
);
}
@Override
public AudioResult process(SynthesisRequest request) {
String traceId = Optional.ofNullable(request.getTraceId()).orElse(UUID.randomUUID().toString());
log.info("Initiating basic TTS synthesis for traceId: {}", traceId);
HttpProfile httpProfile = new HttpProfile();
httpProfile.setEndpoint("tts.tencentcloudapi.com");
ClientProfile clientProfile = new ClientProfile();
clientProfile.setHttpProfile(httpProfile);
TtsClient client = new TtsClient(cloudCredential, "ap-shanghai", clientProfile);
TextToVoiceRequest apiRequest = new TextToVoiceRequest();
apiRequest.setText(request.getContent());
apiRequest.setSessionId(traceId);
apiRequest.setVolume(request.getVolume());
apiRequest.setSpeed(request.getSpeedRate());
apiRequest.setVoiceType(request.getVoiceType());
apiRequest.setProjectId(0L);
apiRequest.setModelType(1L);
apiRequest.setPrimaryLanguage(1L);
apiRequest.setEnableSubtitle(false);
try {
TextToVoiceResponse response = client.TextToVoice(apiRequest);
log.debug("TTS basic synthesis completed. RequestId: {}", response.getRequestId());
return AudioResult.builder()
.mode(AudioMode.SYNC)
.audioData(response.getAudio())
.requestId(response.getRequestId())
.build();
} catch (TencentCloudSDKException ex) {
log.error("TTS synthesis failed for traceId: {}", traceId, ex);
throw new SynthesisException("Audio generation failed");
}
}
@Override
public boolean supports(SynthesisRequest request) {
return request.getContentLength() <= 150;
}
}
Springイベントを用いた非同期処理とファイル保存
長いテキストの合成、あるいは短いテキストであっても生成された音声の保存が必要な場合、同期処理の中でファイルアップロードを行うとレスポンスタイムが増大します。そこで、Springのイベント機構(ApplicationEventPublisher)を利用し、音声生成完了後に非同期でオブジェクトストレージ(COS)へのアップロードを行う仕組みを導入しました。
以下のコードは、音声データの準備が整った段階でイベントを発行するロジックです。
// イベントの発行
AudioUploadEvent uploadEvent = AudioUploadEvent.builder()
.timestamp(Instant.now().getEpochSecond())
.taskId(recordEntity.getId())
.isRemoteSource(false)
.payload(generatedAudioBase64)
.build();
applicationEventPublisher.publishEvent(uploadEvent);
イベントリスナー側では、データソースがBase64エンコードされた文字列か、あるいは非同期APIから返されたURLかを判定し、適切に入力ストリームを生成した上で、COSへアップロードを行います。
@Async
@EventListener
public void handleAudioUpload(AudioUploadEvent event) {
InputStream audioStream = null;
try {
if (event.isRemoteSource()) {
log.info("Downloading audio from remote URL for taskId: {}", event.getTaskId());
MediaFetchCommand command = new MediaFetchCommand(event.getRemoteUrl(), "wav");
audioStream = mediaFetchService.execute(command);
} else {
log.info("Decoding Base64 audio for taskId: {}", event.getTaskId());
byte[] binaryData = Base64.getDecoder().decode(event.getPayload());
audioStream = new ByteArrayInputStream(binaryData);
}
String storagePath = String.format("/assets/audio/%s/%d.wav",
DateUtil.currentMonth(), event.getTaskId());
StorageUploadResult result = cloudStorageService.upload(audioStream, storagePath);
log.info("Successfully uploaded audio to COS: {}", result.getAccessUrl());
// DBステータス更新処理など
updateTaskStatus(event.getTaskId(), Status.COMPLETED, result.getAccessUrl());
} catch (IOException ex) {
log.error("File upload failed for taskId: {}", event.getTaskId(), ex);
} finally {
if (audioStream != null) {
try { audioStream.close(); } catch (IOException ignored) {}
}
}
}
多音字の検出とSSML対応
自然な読み上げを実現する上で最大の課題の一つが多音字(複数の読み方を持つ漢字)です。TTSエンジンに対して正しい読みを指示するためには、SSML(Speech Synthesis Markup Language)を使用する必要があります。例えば、「長」という字を「zhǎng」と読ませる場合は以下のように記述します。
<speak><phoneme alphabet="py" ph="zhang3">長</phoneme></speak>
バックエンドでは、`pinyin4j`ライブラリを活用してテキスト内の多音字を検出する機能を実装しました。以下は、入力テキストを解析し、多音字のリストを抽出するユーティリティクラスの例です。
public class PolyphonicDetector {
public List<PolyphonicChar> analyze(String content) {
List<PolyphonicChar> results = new ArrayList<>();
Set<String> processed = new HashSet<>();
for (char character : content.toCharArray()) {
if (isChineseCharacter(character)) {
String[] readings = PinyinHelper.toHanyuPinyinStringArray(character);
if (readings != null && readings.length > 1 && !processed.contains(String.valueOf(character))) {
PolyphonicChar info = new PolyphonicChar();
info.setCharacter(String.valueOf(character));
info.setPronunciations(Arrays.asList(readings));
results.add(info);
processed.add(String.valueOf(character));
}
}
}
return results;
}
private boolean isChineseCharacter(char c) {
return c >= 0x4E00 && c <= 0x9FA5;
}
}
フロントエンド側では、この検出結果を用いてユーザーに読みを選択させ、選択された読みに基づいてSSMLタグを挿入・置換する処理が必要となります。これにより、正確な発音での音声合成が可能になります。
MP3からMP4へのコンテナ変換
開発過程で、ミニプログラムの仕様によりMP3形式のファイルを直接保存できないという問題に直面しました。ミニプログラムで音声を保存するには、MP4などのビデオコンテナ形式である必要があります。
解決策として、JavaCV(FFmpegのJavaラッパー)を使用し、音声データを黒い単色画像(またはデフォルトのサムネイル画像)を1フレームとして持つMP4動画に変換する処理を実装しました。この変換処理の副次的な利点として、`FFmpegFrameGrabber.getLengthInTime()`を利用することで、当初取得が困難だった音声の正確な再生時間を取得できるようになりました。
変換プロセスでは、`FFmpegFrameRecorder`を使用してオーディオストリームとビデオストリーム(静止画)を多重化(マルチプレックス)します。これにより、音声のみのコンテンツをプラットフォームの要件を満つ動画形式として出力できます。