2025年2月4日火曜日

APEXアプリケーションからVOICEVOXエンジンを呼び出し「ずんだもん」の声で音声合成する

先日作成したDeepSeek-R1を呼び出して小説を生成するAPEXアプリケーションに、小説の読み上げ機能を追加してみました。VOICEVOXエンジンのコンテナで動作するHTTPサーバーに、Oracle APEXが動作しているデータベース・サーバーからリクエストを発行し音声合成をします。

以下の記事を参考にしています。

日経クロステック 2025年1月31日NIFTY engineering blog
社内Webアプリでもずんだもんに喋ってほしいのだ

本記事ではフロントエンドはJavaScript、バックエンドはPL/SQLでの実装になるため、これらの記事のコードを流用しているということはありませんが、VOICEVOXと「ずんだもん」の使い方について参考にさせていただきました。

Oracle APEXのアプリケーションから音声合成を呼び出す手順としては、以前に以下の2本の記事を書いています。概ね似たような実装になっています。

OpenAIのText to speech APIを呼び出して写真の説明を読み上げる

記事「APEX 24.2のデータ・モデルの生成をLM StudioとDeepSeek R1 Distill Llama 70Bで実行する」で作成したAPEXアプリのページ番号2に、以下の小説読み上げ機能を追加します。

読み上げ対象の小説から推論過程にあたる<think>...</think>を削除するスイッチ、小説に書かれている文章からWAVファイルを生成する読み上げるボタン、生成されたWAVファイルを再生するオーディオ・コントールをページに配置します。


MシリーズのMacbook Proのpodmanを使って、VOICEVOXエンジンのコンテナを実行します。

podman run --rm -p 50021:50021 voicevox/voicevox_engine:cpu-latest

VOICEVOXエンジンのコンテナを実行すると、利用規約が表示されます。コマンドを一行実行するだけで、音声合成を行うREST APIを呼び出すことができるようになります。

% podman run --rm -p 50021:50021 voicevox/voicevox_engine:cpu-latest

+ cat /opt/voicevox_engine/README.md

# VOICEVOX エンジン利用規約


## 許諾内容


1. 商用・非商用問わず利用することができます

2. アプリケーションに組み込んで再配布することができます

3. 作成された音声を利用する際は、各音声ライブラリの規約に従ってください

4. 作成された音声の利用を他者に許諾する際は、当該他者に対し本許諾内容の 3 及び 4 の遵守を義務付けてください


## 禁止事項


- 逆コンパイル・リバースエンジニアリング及びこれらの方法の公開すること

- 製作者または第三者に不利益をもたらす行為

- 公序良俗に反する行為


## 免責事項


本ソフトウェアにより生じた損害・不利益について、製作者は一切の責任を負いません。


## その他


ご利用の際は VOICEVOX を利用したことがわかるクレジット表記が必要です。


---

[中略 - 詳細は実際の出力を確認してください。]


利用規約の詳細は以下をご確認ください。

https://zonko.zone-energy.jp/guideline

+ exec gosu user /opt/python/bin/python3 ./run.py --voicelib_dir /opt/voicevox_core/ --runtime_dir /opt/onnxruntime/lib --host 0.0.0.0

Warning: cpu_num_threads is set to 0. Setting it to half of the logical cores.

reading /home/user/.local/share/voicevox-engine-dev/user.dict_csv-7bb7720c-7a5c-4341-84a6-9c9dd9a72dba.tmp ... 79

emitting double-array: 100% |###########################################| 

INFO:     Started server process [1]

INFO:     Waiting for application startup.

INFO:     Application startup complete.

INFO:     Uvicorn running on http://0.0.0.0:50021 (Press CTRL+C to quit)

Info: Loading core 0.15.7.

INFO:     10.88.0.3:37824 - "GET /docs HTTP/1.1" 200 OK

INFO:     10.88.0.3:37824 - "GET /openapi.json HTTP/1.1" 200 OK


上記のコマンドではコンテナのポート50021をホスト・ポート50021にマッピングしているため、REST APIのエンドポイントはhttp://localhost:50021がベースURLになります。OpenAPIによるREST APIのドキュメントは以下より参照できます。


VOICEVOXエンジンの準備は以上で完了です。

以下よりAPEXアプリケーション側での作業を紹介します。

アプリケーション定義置換に、いくつかAPEXアプリケーションが参照する値を置換文字列として設定します。

置換文字列G_VOICEVOX_BASE_URLとして、VOICEVOXエンジンのREST APIのベースURLを設定します。今回の構成はVOICEVOXエンジンを呼び出すOracle Databaseもローカルのコンテナで実行しているため、G_VOICEVOX_BASE_URLとしてhttp://host.containers.internal:50021を設定しています。

置換文字列G_SPEAKERには、REST APIのaudio_queryやsynthesisに引数speakerとして渡す値を設定します。http://localhost:50021/speakersより、番号と音声の対応を参照できます。選択する音声ごとに音声ライブラリ利用規約が設定されているので、確認が必要です。

置換文字列G_VOICEVOX_PROXY_URLに、Oracle REST Data Servicesで実装したREST APIのエンドポイントを設定します。APEXアプリケーションはVOICEVOXエンジンを直接呼び出す代わりに、一旦Oracle REST Data ServicesのREST APIを呼び出し、そこからVOICEVOXエンジンのREST APIを呼び出します。このようにした理由のひとつは、CORS対策です。APEXが生成したページからJavaScriptで直接VOICEVOXエンジンを呼ぼうとすると、VOICEVOXにリクエストが拒否されます。VOICEVOXのコンテナに手を入れたくなかったので、ORDSでリクエストを中継することによりCORS有効化を不要にしています。

置換文字列G_VOICEVOX_PROXY_URL/ords/apexdev/voicevox/audio_query_and_synthesisを設定しています。この値は、ORDS別名(通常はAPEXワークスペース名に同じ)に依存して、環境ごとに変わります。


ページ・デザイナで、読み上げ機能を追加するページ番号のページを開きます。

非表示のページ・アイテムP2_BASE_URLを作成し、置換文字列G_VOICEVOX_BASE_URLの値を、このページで実行するJavaScriptから参照できるようにします。

ソースタイプアイテムアイテムとしてG_VOICEVOX_BASE_URLを設定します。セッション・ステートストレージリクエストごと(メモリーのみ)を選択します。


同様にページ・アイテムP2_SPEAKERから、置換文字列G_SPEAKERの値を参照できるようにします。


ページ・アイテムP2_PROXY_URLから、置換文字列G_VOICEVOX_PROXY_URLの値を参照できるようにします。


読み上げに関するアイテムやボタンを含めるリージョンを作成します。識別名前Readout Containerタイプ静的コンテンツです。外観テンプレートに装飾の無いBlank with Attributesを選択します。

このリージョンにAPEXアクションを保持するコンテキストを作成するため、静的IDとしてREADOUTを設定します。


DeepSeek-R1の出力には、<think>...</think>で囲まれた推論過程が含まれます。小説の読み上げ時には不要な文章なので、小説から除くための切替えとして、ページ・アイテムP2_EXCLUDE_THINKを作成します。ラベルThinkを除くとします。

設定デフォルトの使用オンとします。この場合、切替えがオンでページ・アイテムの値はYオフNになります。レイアウト行CSS クラスu-flex u-align-items-centerを設定し、切替えやボタンの縦方向の位置を中央揃えにします。列スパンを設定し、この切替えの横幅に(横方向に12分割された列の内の)2列を割り当てます。

検証必須の値オン、デフォルト静的値Nとしています。この値は動的コンテンツのリージョン小説のPL/SQLコードから参照するため、セッション・ステートストレージセッションごと(永続)を選択し、セッション・ステートに保存します。


リージョン小説ソースCLOBを返すPL/SQLファンクション本体を、以下のコードに置き換えます。ページ・アイテムP2_EXCLUDE_THINKYであれば、表示する文章から<think>...</think>で囲まれた文字列を取り除きます。

declare
l_end integer;
l_length integer;
l_story clob;
begin
if :P2_STORY is not null then
select story into l_story from ebaj_stories where id = :P2_STORY;
if :P2_EXCLUDE_THINK = 'Y' then
-- DeepSeek-R1の出力はかならず<think>で始まることを想定。
l_length := dbms_lob.getlength(l_story);
l_end := instr(l_story, '</think>') + length('</think>');
-- 出力の先頭はつねに<think>から始まるから、つねに</think>以降を返す。
l_story := dbms_lob.substr(l_story, (l_length - l_end + 1), l_end);
end if;
l_story := '<div id="STORY">' || apex_markdown.to_html(l_story) || '</div>';
return l_story;
end if;
return '';
end;

ボタンREADOUTを作成します。ラベル読み上げるとします。このボタンをクリックして、リージョン小説に表示されている文章からWAVファイルを生成します。生成したWAVファイルは隣に表示されているオーディオ・コントールのソースに設定します。

Thinkの切替えの右隣りに配置するため、レイアウト新規行の開始オフ列スパンとします。

このボタンを押したときに実行される処理は、APEXアクションREADOUTとして実装します。動作アクションとして動的アクションで定義詳細カスタム属性data-action="READOUT"を設定します。


ボタンREADOUTの右隣りにオーディオ・コントロールを配置します。

静的コンテンツのリージョンを作成し、ソースHTMLコードとして以下を記述します。

<audio controls id="story-audio"></audio>


以上で画面上のコンポーネントの配置は完了です。

静的アプリケーション・ファイルとして、このページで実行するJavaScriptのファイルを作成します。フォルダ名js以下に、ファイルreadout.jsとして以下の内容を記述します。

リージョン小説に表示されている文字列を取り出して、それをORDSのREST APIに渡しています。ORDSのREST APIはWAVファイルを返すので、受信したWAVファイルをオーディオ・コントールのsrcに設定しています。


/*
* 音声合成を呼び出す。
*/
const G_VOICEVOX_BASE_URL = apex.item("P2_BASE_URL").getValue();
const G_SPEAKER_ID = apex.item("P2_SPEAKER").getValue();
const G_VOICEVOX_PROXY_URL = apex.item("P2_PROXY_URL").getValue();
const a_READOUT = {
name: "READOUT",
action: (event, element, args) => {
const request = {
base_url: G_VOICEVOX_BASE_URL,
speaker: G_SPEAKER_ID,
text: document.getElementById("STORY").textContent
// text: "音声で回答して"
};
apex.debug.info(request);
fetch(G_VOICEVOX_PROXY_URL,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
}
)
.then(response => response.blob())
.then(blob => {
const audioURL = URL.createObjectURL(blob);
const elem = document.getElementById("story-audio");
elem.src = audioURL;
});
}
}
/*
* APEXアクションの定義。
*/
const readoutContext = apex.actions.createContext('readout-novel', document.getElementById("READOUT"));
readoutContext.add([ a_READOUT ]);
view raw readout.js hosted with ❤ by GitHub

ページ・プロパティJavaScriptファイルURLに以下を記述し、readout.jsをロードするようにします。

[module,defer]#APP_FILES#js/readout#MIN#.js


VOICEVOXエンジンは、Oracle REST Data Servicesのサービスから呼び出します。長いコードをORDSのRESTハンドラに記述すると開発が面倒になるため、音声合成の呼び出しはパッケージEBAJ_VOICEVOX_PROXYにまとめることにしました。

パッケージ定義部です。

RESTハンドラから呼び出すのはaudio_query_and_synthesisのみです。

create or replace package ebaj_voicevox_proxy
as
/**
* 2つのWAVファイルをマージするプロシージャ。
* OpenAI o3-mini-highに書いてもらった。
*/
PROCEDURE merge_wav_files (
p_file1 IN BLOB, -- First WAV file (with standard 44-byte header)
p_file2 IN BLOB, -- Second WAV file (with standard 44-byte header)
p_merged OUT BLOB -- Output: merged WAV file as a BLOB
);
/**
* VOICEVOXの/audio_queryを呼び出す。
*/
function audio_query(
p_text in clob
,p_base_url in varchar2
,p_speaker in number
)
return clob;
/**
* VOICEVOXのsynthesisを呼び出す。
*/
function synthesis(
p_query in clob
,p_base_url in varchar2
,p_speaker in number
)
return blob;
/**
* VOICEVOXのaudio_queryとsynthesisを呼び出す。
*/
function audio_query_and_synthesis(
p_text in clob
,p_base_url in varchar2
,p_speaker in number
)
return blob;
end ebaj_voicevox_proxy;
/

パッケージ本体です。APEXのページから送信された小説本文を改行で区切り、一行ごとに音声合成を呼び出します。変換されたWAVファイルをすべて連結して、呼び出し元のAPEXページに戻します。

create or replace package body ebaj_voicevox_proxy
as
/**
* 2つのWAVファイルをマージするプロシージャ。
*
* OpenAI o3-mini-highに書いてもらった。
*
* function int_to_le_rawのコメントが日本語なのは、WAVをマージするプロシージャを
* 書いてもらったときのプロンプトは英語で、CHR型の代わりにRAWを使ってと修正を依頼した
* ときのプロンプトが日本語だったから。
*/
PROCEDURE merge_wav_files (
p_file1 IN BLOB, -- First WAV file (with standard 44-byte header)
p_file2 IN BLOB, -- Second WAV file (with standard 44-byte header)
p_merged OUT BLOB -- Output: merged WAV file as a BLOB
)
IS
-------------------------------------------------------------------------
-- Local variables.
-------------------------------------------------------------------------
v_header RAW(44); -- Original header from the first file.
v_new_header RAW(44); -- Modified header for the merged file.
v_data_len1 INTEGER; -- Audio data length in file1 (in bytes).
v_data_len2 INTEGER; -- Audio data length in file2 (in bytes).
v_new_data_len INTEGER; -- Combined data length.
v_total_file_sz INTEGER; -- New overall file size field value.
-------------------------------------------------------------------------
-- Helper function: Convert an integer into a 4-byte little-endian RAW.
--
-- WAV file header fields (e.g. overall file size and data length)
-- are stored as 4-byte little-endian integers.
-------------------------------------------------------------------------
FUNCTION int_to_le_raw(p_int IN INTEGER) RETURN RAW IS
l_byte1 RAW(1);
l_byte2 RAW(1);
l_byte3 RAW(1);
l_byte4 RAW(1);
l_result RAW(4);
BEGIN
-- 下位バイトから順に、16進数2桁の文字列に変換してからRAWに変換
l_byte1 := hextoraw(LPAD(TO_CHAR(MOD(p_int, 256), 'FMXX'), 2, '0'));
l_byte2 := hextoraw(LPAD(TO_CHAR(MOD(TRUNC(p_int/256), 256), 'FMXX'), 2, '0'));
l_byte3 := hextoraw(LPAD(TO_CHAR(MOD(TRUNC(p_int/256/256), 256), 'FMXX'), 2, '0'));
l_byte4 := hextoraw(LPAD(TO_CHAR(MOD(TRUNC(p_int/256/256/256), 256), 'FMXX'), 2, '0'));
-- 4つのRAWを連結(リトルエンディアン:最下位バイトが先頭)
l_result := utl_raw.concat(utl_raw.concat(l_byte1, l_byte2),
utl_raw.concat(l_byte3, l_byte4));
RETURN l_result;
END;
BEGIN
-- Check that each WAV file is at least 44 bytes long.
IF DBMS_LOB.getlength(p_file1) < 44 OR DBMS_LOB.getlength(p_file2) < 44 THEN
raise_application_error(-20001, 'One of the WAV files is too short to be valid.');
END IF;
-------------------------------------------------------------------------
-- Extract the header from the first WAV file.
-- In a standard PCM WAV file the header is 44 bytes:
--
-- Bytes 1-4: "RIFF"
-- Bytes 5-8: Overall file size (file size - 8) [little-endian]
-- Bytes 9-12: "WAVE"
-- Bytes 13-16: "fmt "
-- Bytes 17-40: Format information (subchunk size, audio format,
-- number of channels, sample rate, byte rate, etc.)
-- Bytes 37-40: "data" (the literal string)
-- Bytes 41-44: Data chunk size [little-endian]
-------------------------------------------------------------------------
v_header := DBMS_LOB.SUBSTR(p_file1, 44, 1);
-- Compute the length of the audio (data) portion in each file.
v_data_len1 := DBMS_LOB.getlength(p_file1) - 44;
v_data_len2 := DBMS_LOB.getlength(p_file2) - 44;
v_new_data_len := v_data_len1 + v_data_len2;
-- The WAV header “overall file size” (bytes 5-8) equals the final file size minus 8.
-- For a standard 44-byte header, this value becomes 36 + (data size).
v_total_file_sz := 36 + v_new_data_len;
-------------------------------------------------------------------------
-- Build the new header:
--
-- • Keep the first 4 bytes (i.e. "RIFF") unchanged.
-- • Replace bytes 5-8 with the new overall file size (as a little-endian RAW).
-- • Keep bytes 9-40 unchanged.
-- • Replace bytes 41-44 with the new data length (as a little-endian RAW).
-------------------------------------------------------------------------
v_new_header :=
UTL_RAW.SUBSTR(v_header, 1, 4) || -- "RIFF"
int_to_le_raw(v_total_file_sz) || -- new overall file size (bytes 5-8)
UTL_RAW.SUBSTR(v_header, 9, 32) || -- bytes 9-40 (unchanged header parts)
int_to_le_raw(v_new_data_len); -- new data chunk size (bytes 41-44)
-------------------------------------------------------------------------
-- Create a temporary LOB to hold the merged file.
-------------------------------------------------------------------------
DBMS_LOB.CREATETEMPORARY(p_merged, TRUE, DBMS_LOB.CALL);
-------------------------------------------------------------------------
-- Write the new header to the merged file.
-------------------------------------------------------------------------
DBMS_LOB.WRITEAPPEND(p_merged, UTL_RAW.LENGTH(v_new_header), v_new_header);
-------------------------------------------------------------------------
-- Append the audio data from the first file.
-- We copy from position 45 (i.e. skipping the 44-byte header) for v_data_len1 bytes.
-------------------------------------------------------------------------
DBMS_LOB.COPY(
dest_lob => p_merged,
src_lob => p_file1,
amount => v_data_len1,
dest_offset => DBMS_LOB.getlength(p_merged) + 1, -- should be 45 after header write
src_offset => 45
);
-------------------------------------------------------------------------
-- Append the audio data from the second file (again, skipping its header).
-------------------------------------------------------------------------
DBMS_LOB.COPY(
dest_lob => p_merged,
src_lob => p_file2,
amount => v_data_len2,
dest_offset => DBMS_LOB.getlength(p_merged) + 1,
src_offset => 45
);
EXCEPTION
WHEN OTHERS THEN
-- In case of error, free the temporary LOB if needed.
IF DBMS_LOB.ISTEMPORARY(p_merged) = 1 THEN
DBMS_LOB.FREETEMPORARY(p_merged);
END IF;
RAISE;
END merge_wav_files;
/**
* VOICEVOXの/audio_queryを呼び出す。
*/
function audio_query(
p_text in clob
,p_base_url in varchar2
,p_speaker in number
)
return clob
as
l_response clob;
l_url clob;
e_api_call_failed exception;
begin
l_url := p_base_url || '/audio_query?text=' || utl_url.escape(p_text, false, 'AL32UTF8') || '&speaker=' || p_speaker;
apex_web_service.clear_request_headers();
apex_web_service.set_request_headers('accept', 'application/json', p_reset => false);
l_response := apex_web_service.make_rest_request(
p_url => l_url
,p_http_method => 'POST'
,p_body => ''
);
if apex_web_service.g_status_code <> 200 then
raise e_api_call_failed;
end if;
return l_response;
end audio_query;
/**
* VOICEVOXのsynthesisを呼び出す。
*/
function synthesis(
p_query in clob
,p_base_url in varchar2
,p_speaker in number
)
return blob
as
l_query clob;
l_blob blob;
l_url varchar2(400);
e_api_call_failed exception;
begin
l_url := p_base_url || '/synthesis?speaker=' || p_speaker;
apex_web_service.clear_request_headers();
apex_web_service.set_request_headers('Content-Type', 'application/json', p_reset => false);
apex_web_service.set_request_headers('accept', 'audio/wav', p_reset => false);
l_blob := apex_web_service.make_rest_request_b(
p_url => l_url
,p_http_method => 'POST'
,p_body => p_query
);
if apex_web_service.g_status_code <> 200 then
raise e_api_call_failed;
end if;
return l_blob;
end synthesis;
/**
* 生成されたWAVを確認するためのファンクション。呼び出さない。
*
動作確認用の表
-----
create table checkwav (
id number generated by default on null as identity
constraint checkwav_id_pk primary key,
idx number,
text clob,
query clob,
wav blob
);
*/
procedure log_wav(
p_idx in number
,p_text in clob
,p_query in clob
,p_wav in blob
)
as
pragma autonomous_transaction;
begin
-- insert into checkwav(idx, text, query, wav) values(p_idx, p_text, p_query, p_wav);
commit;
end log_wav;
/**
* VOICEVOXのaudio_queryとsynthesisを呼び出す。
*
* 長文をVOICEVOXに送ると、synthesisででエラーが発生する。
* なので、改行ごとにテキストを送信し、WAVファイルに変換している。
* もし、改行で区切られていず、長い文字列が渡されるとやはりエラーとなるが、
* これといった対応はしていない。
*
* 句読点で区切っても良いが、元々の文章に改行を入れた方が良いと思う。
*
* WAVファイルに変換した後、今度はそれをマージして、ひとつのWAVファイルにしている。
* それを呼び出し元に戻している。
*/
function audio_query_and_synthesis(
p_text in clob
,p_base_url in varchar2
,p_speaker in number
)
return blob
as
l_query clob; -- チャンクごとのaudio_queryの結果
l_wav0 blob; -- すべてのWAV
l_wav blob; -- チャンクごとのWAV
l_text clob; -- 音声にするテキスト - すべて
l_text0 varchar2(4000); -- 音声にするテキスト、改行で分割
l_text_array apex_t_varchar2;
begin
/* 改行で分割 */
l_text_array := apex_string.split(p_text);
l_wav0 := null;
for i in 1..l_text_array.count
loop
l_text0 := l_text_array(i);
if length(l_text0) > 0 then
l_query := audio_query(l_text0, p_base_url, p_speaker);
l_wav := synthesis(l_query, p_base_url, p_speaker);
log_wav(i, l_text0, l_query, l_wav);
if l_wav0 is null then
l_wav0 := l_wav;
else
merge_wav_files(l_wav0, l_wav, l_wav0);
-- l_wav0 := l_wav;
end if;
end if;
end loop;
return l_wav0;
end audio_query_and_synthesis;
end ebaj_voicevox_proxy;
/
WAVファイルを連結するプロシージャは、ChatGPTで最近使えるようになったo3-mini-highで生成しました。以下のプロンプトでほとんど動くコードが生成されたのは、結構な驚きです。
Please write a procedure to merge 2 wav files into one by PL/SQL of Oracle?

最初に生成されたコードはリトルエンディアンの4バイト表現にCHAR型を使っている点で問題があったのですが、それも以下のプロンプトで修正されたコードが生成されました。

以下をCHARではなくRAWで実装してください。
-----
   FUNCTION int_to_le_raw(p_int IN INTEGER) RETURN RAW IS
      v_byte1 CHAR(1);
      v_byte2 CHAR(1);
      v_byte3 CHAR(1);
      v_byte4 CHAR(1);
   BEGIN
      v_byte1 := CHR(MOD(p_int, 256));
      v_byte2 := CHR(MOD(TRUNC(p_int/256), 256));
      v_byte3 := CHR(MOD(TRUNC(p_int/256/256), 256));
      v_byte4 := CHR(MOD(TRUNC(p_int/256/256/256), 256));
      RETURN UTL_RAW.CAST_TO_RAW(v_byte1 || v_byte2 || v_byte3 || v_byte4);
   END;
ChatGPTがPL/SQLではかなり難しいバイナリ・データを操作するコードを生成してくれたので、とても助かりました。

後は、ebaj_voicevox_proxy.audio_query_and_synthesisを呼びだすORDSのハンドラを作成します。

モジュールとしてvoicevoxテンプレートとしてaudio_query_and_synthesisを作成し、POSTハンドラのソースに以下を記述します。

declare
l_wav blob;
l_json json_object_t;
l_text clob;
l_base_url varchar2(400);
l_speaker number;
e_api_call_failed exception;
begin
l_json := json_object_t(:body_json);
l_text := l_json.get_clob('text');
l_base_url := l_json.get_string('base_url');
l_speaker := l_json.get_number('speaker');
l_wav := ebaj_voicevox_proxy.audio_query_and_synthesis(l_text, l_base_url, l_speaker);
/* wavとしてダウンロード */
sys.htp.init;
sys.htp.p('Content-Length: ' || dbms_lob.getlength(l_wav));
sys.htp.p('Content-Type: audio/wav');
sys.htp.p('Content-Disposition: attachment');
sys.owa_util.http_header_close;
sys.wpg_docload.download_file(l_wav);
end;


作成したRESTサービスはAPEXのセッションで保護できます。今回は実装しませんが、以前のOpenAIやAzureでの記事では、API呼び出しが有料なためAPEXセッションによる保護を実装しています。

以上でアプリケーションは完成です。

今回作成したAPEXアプリケーションのエクスポートを以下に置きました。APEX 24.2で作成しているため、それ以前のバージョンではインポートできません。
https://github.com/ujnak/apexapps/blob/master/exports/novel-generator-synthesis.zip

Oracle APEXのアプリケーション作成の参考になれば幸いです。