まだプログラマーですが何か?

プログラマーネタ中心。たまに作成したウェブサービス関連の話も https://twitter.com/dotnsf

このネタを見た時は「やられた!」と思いました:
手持ちの画像を「Excel」ファイルにしてしまうWebアプリ「Image to Excel」が爆誕

画像をアップロードして、そのピクセル情報を解析して(よりによって)エクセルファイル化する・・・ 素晴らしい変態アプリだと思いました(褒めてます)。 これをアイデアだけでなく実装して公開する行動力まで含めて素晴らしい姿勢だと感じました。自分もこうでありたい。。

で、その衝撃が強すぎたのか、「これ自分だったらどう作るだろうか??」と考え、特にセルの属性を変更したエクセルファイルとして出力する所は何をどうやれば実現できそうか・・ なども調べた上で、なんとか同じような機能を実装することができたと思っています。自分の場合はアプリケーションというよりは REST API として実装したので、この機能を外部アプリからも使えるような形で仕上げています(Swagger ドキュメントも用意したのでウェブブラウザの UI からも使えるはずです)。実装したものは先日このブログでも紹介した無料の PaaS 環境を使って公開しています:

ソースコード
 https://github.com/dotnsf/image2xlsx
公開した REST API(の Swagger ドキュメント)
 https://image2xlsx.140.238.50.190.sslip.io/ (使い方も後述します)


で、この機能を作る上で xlsx-js-style という npm ライブラリを使っています。このライブラリは Node.js やフロントエンド JavaScript からエクセルファイルを生成する機能を持ったライブラリです。もともとは SheetJS というライブラリ(無料)があったのですが、有料版の提供を境にして無料版のサポートが少なくなってしまったのか、更新が途絶えてしまいました。そこで SheetJS からフォークして様々な後継ライブラリが作られていったのですが、xlsx-js-style はその1つです。ただ詳しい使い方がウェブ上でもあまり見つけることができず、一部では「事実上、読み取り用のライブラリ」とも書かれていました。実際はそんなことはなくて、現に今回紹介するようなエクセルブックファイルを新規作成する上での最低限の機能を持っていました。一方で情報が少ないことも事実で、今回はソースコードの中身を含めて自分でも色々調べながらの作業となりました。その調べて分かった内容も含めて本ブログエントリにまとめておこうと思います。

なお以下で紹介する内容は 2023/12/08 時点で公開しているサービスと、そのソースコードについて紹介しています。今後の更新で多少実装内容が変わる可能性もあることをご了承ください。


【image2xlsx (の Swagger ドキュメント)の使い方】
何はともあれ公開している機能の使い方を紹介します。作ったのは REST API なんですが、Swagger ドキュメントも併せて公開しているので curl などを使わなくてもウェブブラウザで実際の挙動を確認できるように(つまり手持ちの画像をアップロードして画像化したエクセルファイルをダウンロードすることができるように)しています。ウェブブラウザでこの URL にアクセスしてみてください:
https://image2xlsx.140.238.50.190.sslip.io/

2023120701


↑上のような画面が表示されれば成功です。https スキーマを使ってアクセスしているはずなので、実際に使う前に Schemas と書かれた欄(上図の赤枠部分)を "HTTP" から "HTTPS" に変更しておいてください(このサイトのサービスを使う場合、あらかじめ HTTPS に変更しておかないと正しく実行できません)。

この Swagger ドキュメントで提供している REST API は1つだけで、"POST /image" です。緑色の "POST" と書かれた部分をクリックして展開してみましょう:
2023120702


省略されていた部分が展開されて上のような画面になったはずです。この画面で "POST /image" API にはいくつかのパラメータが指定できることがわかります。が、まずは実際に API を実行してみたいので "Try it out" と書かれたボタンをクリックします:
2023120703


パラメータ部分が編集可能状態に切り替わり、一番下には "Execute" と書かれた青いボタンが現れたはずです。これでパラメータを指定して "Execute" ボタンをクリックすると実際に REST API が動く状態になりました。パラメータは4つありますが、入力必須なのは一番下の file フィールドだけです。他はいったん無視して file フィールドのボタンをクリックして適当な画像ファイル(.jpg, .gif, .png など)を指定してください(2023/12/08 時点では 10MB 以下の画像ファイルを受け付けることができます。もっと大きなファイルを扱えるようにしたい場合は是非ソースコードをダウンロードして自分で改良してみてください)。この際に日本語名のファイルを指定するとダウンロードするエクセルファイル名が文字化けしてしまうので、(ファイル名の文字化け程度を許容できる場合はそのままでもいいですが)できれば半角英数字名だけで構成されたファイル名の画像ファイルを指定してください:
hachi
(↑hachi.jpg というこの画像ファイルを指定してみました)


ファイルを選択したら "Execute" ボタンをクリックします:
2023120704


すると指定したファイルがアップロードされ、エクセルファイル化が実行されます(実行中は "LOADING" と表示されます):
2023120705


正しく処理が完了すると "Responses" 欄が現れます。ここには実際に実行した REST API の内容(curl コマンドで実行した時のコマンドの内容)やその実行結果のレスポンスに関する情報が表示されます。処理が成功していると "Download file" と書かれたリンクが表示されていて、ここをクリックすると実行結果のエクセルファイルをダウンロードできます:
2023120706


実際に "Download file" をクリックします。ダウンロードは一瞬のはずです。ちなみにエクセルファイル名は元の画像ファイル名の拡張子部分が "xlsx" になったものです(hachi.jpg → hachi.xlsx):
2023120707


ダウンロードしたファイルを(ダブルクリックするなどして)エクセルやエクセル互換ツールで開いてみると期待通りのエクセルシートが表示されるはずです(シート名は元の画像ファイル名です)。元祖の Image to Excel と同じような結果になっているはずです:
2023120708
(↑シートに画像が貼られているのではなく、細かなセルの色を変えることで実現しています)


なお今回はアップロードする画像以外のパラメータを無視していました。これらのパラメータの意味とデフォルト値について簡単に説明しておきます:
2023120709


px パラメータは「エクセルファイル側で画像の1ピクセルを何ピクセルのセルで表現するか」という数値です。デフォルト値は「2」です。つまり何も指定しなければ画像の各ピクセルは「横 2px 縦 2px」のサイズのセルとして表示されます。この値を変えたい場合は px パラメータになんらかの数値(1とか3あたり)を入力してください。

width パラメータは「エクセルファイル側で画像の横幅を何個ぶんのセルを使って表現するか」の数です。デフォルト値は「100」です。つまり何も指定しなければアップロードされた画像の横幅サイズを 100 に縮小(または拡大)します。

同様に height パラメータは「エクセルファイル側で画像の縦幅を何個ぶんのセルを使って表現するか」の数です。デフォルト値はなく、何も指定されなかった場合は縦横比を保ったまま横サイズ(こちらのデフォルト値は 100)に合わせて縦幅が拡大縮小されます。

なお height パラメータのみ指定されて width パラメータが指定されていなかった場合は、縦横比を保ったまま縦サイズ(=height)に合わせて横幅が拡大縮小されます。

このような仕様となっていました。なので、これらを何も指定せずに実行した場合は、
・画像の1ピクセルは 2x2 px サイズのセルで表示される(そのピクセルの色がセルの色になる)
・横幅は 100px に拡大縮小され、縦幅は同じ比率で拡大縮小される
ということになります。何も指定しなければ横幅は 100 になるので、巨大な画像をアップロードしても、そこまで巨大なエクセルファイルにはならないはずです。よかったら(必要に応じてパラメータも変えたりしながら)使ってみてください。


【image2xlsx REST API の使い方】
ここからは Swagger ドキュメントの使い方というよりは REST API の使い方を説明します。改めて実行結果の Responses 部分の、"Curl" と書かれた部分を見てみます:
2023120701


file 以外のパラメータを指定せずに実行した場合はこんな感じに書かれているはずです:
curl -X POST "https://image2xlsx.140.238.50.190.sslip.io/image" -H "accept: application/vnd.ms-excel" -H "Content-Type: multipart/form-data" -F "file=@hachi.png;type=image/png"

ちなみにパラメータを指定した場合(例えば width パラメータを 200 に指定して実行した場合)はこのようになります(-F "width=200" が追加されています):
curl -X POST "https://image2xlsx.140.238.50.190.sslip.io/image" -H "accept: application/vnd.ms-excel" -H "Content-Type: multipart/form-data" -F "width=200" -F "file=@hachi.png;type=image/png"

これがこの画像ファイルをエクセルファイル化する時に実行された curl コマンドそのものでもあります。REST API としては以下のような仕様です:

・エンドポイント URL は "https://image2xlsx.140.238.50.190.sslip.io/image"
・HTTP メソッドは "POST"
・HTTP リクエストヘッダは "accept: application/vnd.ms-excel" と "Content-Type: multipart/form-data"
・HTTP リクエストボディは以下を FormData で送信
 - px パラメータでエクセルファイルのセル1つのサイズを何ピクセル平方にするか
 - width パラメータでエクセルファイルのセルを何列で表現するか
 - height パラメータでエクセルファイルのセルを何行で表現するか
 - file パラメータで画像ファイル本体(ここだけ必須)

・HTTP レスポンスはエクセルファイルのバイナリデータ

(需要があるとは思えないのですが)ここを理解した上で外部アプリからこのエンドポイントへファイルをアップロードすればエクセルファイル化してダウンロードすることもできるようになります。REST API 化したので外部連携も可能な形になっています(サーバーがそこまで潤沢な環境ではないので、お手柔らかに使ってください)。


【xslx-js-style をどのように使ったか】
ここまでの内容は「本家の Image to Excel をパクってみました」という内容でした。ここからがこのブログエントリの本体ともいえる部分だと思っていて、「これをどうやって作ったのか」という技術的な解説になります。本家のサービスがどのように作られているのかは分からないのですが、自分はこうやってみた、という紹介です。ちなみにプログラミング言語は Node.js で作っています。

改めて、私が作った image2xlsx のソースコードはこちらで公開しています。実際の実装ソースコードはここからも参照できるので興味ある方は是非どうぞ。

この "POST /image" メソッドは概ね以下のようなフローで作られています:
(1)一時フォルダにアップロードされた画像ファイルを受け取る
(2)画像ファイルを開いて、サイズ(横幅、縦幅)を調べる
(3)受け取ったパラメータなどから出力時のサイズ(横幅、縦幅)を求める
(4)Canvas ライブラリを使って仮想的なキャンバスを作り、画像を出力時のサイズで描画する
(5)エクセルワークブック(ファイル)を新規に作成する
(6)エクセルワークシート(タブ)を新規に作成する
(7)(3)で求めたサイズに合わせて画像の各ピクセルぶんのセルをワークシート内に用意する
(8)(4)の画像データを取り出し、1ピクセルごとに RGB 値を取得し、
(9)(8)の結果を各セルの背景色に指定する
(10)セルの色を指定したワークシートをワークブックに追加する
(11)ワークブックのデータを取り出し、HTTP レスポンスとして返す

以下、このフローの実装内容を順に説明していきます。まず(1)ですが、ここは multer というファイルアップロード機能を作る時の定番ライブラリがあるので multer を使って実装しています。

該当コードとしてはこのような感じです:
var express = require( 'express' ),
    bodyParser = require( 'body-parser' ),
    fs = require( 'fs' ),
    multer = require( 'multer' ),
    { createCanvas, Image } = require( '@napi-rs/canvas' ),
    XLSX = require( 'xlsx-js-style' ),
    app = express();

require( 'dotenv' ).config();
var default_px = 'DEFAULT_PX' in process.env ? parseInt( process.env.DEFAULT_PX ) : 2;
var default_width = 'DEFAULT_WIDTH' in process.env ? parseInt( process.env.DEFAULT_WIDTH ) : 100;

app.use( express.static( __dirname + '/public/_doc' ) );
app.use( bodyParser.urlencoded( { extended: true } ) );
app.use( bodyParser.json( { limit: '10mb' } ) );
app.use( express.Router() );
app.use( multer( { dest: './tmp/' } ).single( 'file' ) );

  :

app.post( '/image', function( req, res ){
  var imgpath = req.file.path;
  var outputfilename = '';
  try{
    var imgtype = req.file.mimetype;
    var filename = req.file.originalname;

    :


(2)以降で仮想キャンバス機能を使います。フロントエンド JavaScript では HTML5 の Canvas に相当する機能なのですが、node-Canvas というバックエンド用の互換ライブラリ(のプラットフォーム非互換性を解消した @napi-rs/canvas というライブラリ)を使っています。この canvas でアップロードされた画像ファイルを読み込み、横幅と縦幅を取り出します。

(3)は、画像ファイルと一緒に送られてきたサイズに関するパラメータを使い(送られていない場合はデフォルト設定値を使い)、必要に応じて元画像の縦横比に合わせる形で計算も行って、エクセルファイル内のセルの横幅数と縦幅数を求めておきます。

この(2)と(3)に相当する部分のコードはこのようになっています:
    :
    //. 画像解析
    var data = fs.readFileSync( imgpath );
    var img = new Image;
    img.src = data;

    var width = req.body.width ? parseInt( req.body.width ) : -1;
    var height = req.body.height ? parseInt( req.body.height ) : -1;
    if( width < 0 && height < 0 ){
      width = default_width;  //. サイズの指定が何もない場合、幅100とする
      height = width * img.height / img.width;
    }else if( width < 0 ){
      width = height * img.width / img.height;
    }else if( height < 0 ){
      height = width * img.height / img.width;
    }

    :


(4)では仮想的な Canvas を作って、Canvas 内に元画像を元のサイズから出力時のサイズに拡大・縮小したものを表示(仮想的な Canvas なので実際に表示されるわけではないですが)しています。Canvas に描画されたデータはピクセルごとの情報(今回の場合であれば RGB 値)を取り出して調べることができるようになります。

この部分のコードはこのようにしています:
    :
    //. リサイズして仮想キャンバスに描画
    var canvas = createCanvas( width, height );
    var ctx = canvas.getContext( '2d' );
    ctx.drawImage( img, 0, 0, img.width, img.height, 0, 0, width, height );

    //. 描画した画像のデータを取得
    var imagedata = ctx.getImageData( 0, 0, width, height );
    :

ここまでは(アップロードされた画像を拡大縮小して、各ピクセル毎の情報を取り出すための準備までは) xlsx-js-style ライブラリを使わずに実装しています。ここからが xlsx-js-style ライブラリの出番です。

(5)この後、各ピクセル毎の情報を調べていくのですが、その前にワークブックを新規に作成します。「ワークブック」=「エクセルファイル」と考えると理解しやすいと思いますが、ここでの「新規作成」の意味は「新しいエクセルファイルを作って、まだ保存していない状態にする」と思っていてください。なおこの時点ではワークブック内に1つもタブが存在していない状態で作成されています。

xlsx-js-style でワークブックを新規作成するには utils.book_new() というメソッドを実行します:
    :
    //. ワークブックを作成
    var wb = XLSX.utils.book_new();
    :

(6)ではワークブックに追加することになるワークシート(タブ)を新規に1つ作成します。

xlsx-js-style でワークシートを新規作成するには utils.aoa_to_sheet() というメソッドを実行します。引数にワークシート内の各列の情報を含めることができますが、この時点では特に指定せずに作成しています:
    :
    //. ワークシートを作成
    var row = [[]];
    var ws = XLSX.utils.aoa_to_sheet([row]);
    :


(7)はワークシート内で使うことになるセルの列数と行数(つまり画像の横幅と縦幅)の分だけセルのサイズを調整します。

ここでの「セルのサイズ」とは「REST API 実行時に px パラメータとして指定された値(指定されていない場合は default_px の値(=2)」です。また「セルの列数」とは「Canvas 上に表示されている画像の横幅(=imagedata.width)」で、「セルの行数」とは「Canvas 上に表示されている画像の縦幅(=imagedata.height)」のことです。したがって「セルの列数の分だけセルのサイズを調整する」コードは以下のようになります。具体的には各列の幅を { wpx: (セル幅のピクセル数)} というオブジェクトで定義する配列を用意してワークシートオブジェクトの '!cols' 値として設定し、各行の幅は [ hpx: (セル高さのピクセル数) } というオブジェクトで定義する配列を用意してワークシートオブジェクトの '!rows' 値として設定します。具体的には以下のようなコードになります:
    :
    //. 必要なセルの行数と列数ぶんだけ、幅と高さを指定する
    var px = req.body.px ? parseInt( req.body.px ) : default_px;
    var wscols = [];
    for( var x = 0; x < imagedata.width; x ++ ){
      wscols.push( { wpx: px } );
    }
    var wsrows = [];
    for( var y = 0; y < imagedata.height; y ++ ){
      wsrows.push( { hpx: px } );
    }

    ws['!cols'] = wscols;
    ws['!rows'] = wsrows;
    :

(8)では Canvas 内の各ピクセルにアクセスして RGB 値をそれぞれ取り出し、16進法の "RRGGBB" フォーマットに変換して、

(9)その "RRGGBB" 値をワークシート内の対応するセルの背景色として指定してゆきます。

この(8)と(9)はループ内で以下のように実現しています。ピクセルの RGB 値は Canvas のデータ内に4つごとに(R 値、G 値、B 値、輝値)格納されているのでそれぞれを取り出し、この値を "RRGGBB" というフォーマットに変えて、"A1", "A2", ... といったセルの色として指定しています。また cellname という関数を用意して縦位置、横位置からセル名に変換できるようにしています(この後の(10)でも使っています):
    :
    //. 1ピクセルずつ取り出して色を調べる
    for( var y = 0; y < imagedata.height; y ++ ){
      for( var x = 0; x < imagedata.width; x ++ ){
        var index = ( y * imagedata.width + x ) * 4;
        var rr = imagedata.data[index];    //. 0 <= xx < 256
        var gg = imagedata.data[index+1];
        var bb = imagedata.data[index+2];
        var alpha = imagedata.data[index+3];

        var rrggbb = rgb2hex( rr, gg, bb );
        var cname = cellname( x, y );
        ws[cname] = { v: "", f: undefined, t: 's', s: { fill: { fgColor: { rgb: rrggbb } } } };
      }
    }
    :
    :

function color2hex( c ){
  var x = c.toString( 16 );
  if( x.length == 1 ){ x = '0' + x; }
  return x;
}

function rgb2hex( r, g, b ){
  return color2hex( r ) + color2hex( g ) + color2hex( b );
}

function cellname( x, y ){
  //. String.fromCharCode( 65 ) = 'A';
  var c = '';
  var r = ( y + 1 );

  while( x >= 0 ){
    var m = x % 26;
    x = Math.floor( x / 26 );

    c = String.fromCharCode( 65 + m ) + c;
    if( x == 0 ){
      x = -1;
    }else{
      x --;
    }
  }

  return ( c + r );
}

(10)(7)、(8)、(9)の処理によって変更されたワークシートの内容をワークブックに反映します。これでレスポンスとして返すエクセルファイル(のデータ)が完成しました。

このコードでは実際に使ったセルの範囲を "A1:(最も右下にあるセルの名前)"  という形でワークシートの '!ref' 値に指定してから、作成したワークシートをワークブックに追加する、という処理を実行しています:
    :
    //. 値が適用される範囲を指定
    var cname = cellname( imagedata.width - 1, imagedata.height - 1 );
    ws['!ref'] = 'A1:' + cname;

    //. ワークシートをワークブックに追加
    XLSX.utils.book_append_sheet( wb, ws, filename );
    :


(11)最後に完成したエクセルファイルを HTTP リクエストに対する HTTP レスポンスとして返します。

ここでは、これまでの処理で作成したワークブックのバッファデータを取得して HTTP レスポンスヘッダと合わせる形で返す、ということになるのですが、xlsx-js-style のソースコードを調べてもワークブックのバッファデータを直接取得する関数は用意されていないようでした。というわけで、一旦ワークブックファイルとして書きだし、ワークブックファイルを読み込んだ上で返す(そしてワークブックファイルをアップロード画像と一緒に消す)、というアルゴリズムにしています:
    :
    //. xlsx化
    XLSX.writeFileSync( wb, 'tmp/' + outputfilename );

    res.contentType( 'application/vnd.ms-excel' );
    res.setHeader( 'Content-Disposition', 'attachment; filename="' + outputfilename + '"' );
    var bin = fs.readFileSync( 'tmp/' + outputfilename );
    res.write( bin );
    res.end();
    :

・・と、このような形で xlsx-js-style を使ってエクセルファイルをバックエンドで作成することができました。グラフとかまでは難しそうではありますが、セルのサイズや色を指定したワークシートを作るという程度であれば実現できないことはなさそうで、今後エクセル連携が必要になった場合でも使えそうなライブラリでした。





このブログエントリは IBM Cloud アドベントカレンダー 2023 に参加しています(西川さん、いつもありがとうございます!)。12/04 ぶんの記事です:
2023112901



IBM Cloud から提供されている生成 AI のプラットフォーム(および API )の watsonx.ai を使って英会話練習アプリを作って、公開してみました:
https://github.com/dotnsf/wwx

2023112902



【使うための準備】
アプリケーションを動かすには以下が必要です(IBM Cloud 上での手続きが必要ですが、フリープランを使って無料枠内で運用するぶんには料金はかかりません):
Git および Node.js がインストールされ、インターネットに繋がる PC(Windows, MacOS, Linux, ..)と Chrome ブラウザ
IBM Cloud のアカウント
・IBM Cloud の API キー
・IBM Cloud 内で Watson Studio および Watson Machine Learning のインスタンスをダラスのデータセンターで有効化
・IBM Watsonx.AI のプロンプトラボを使った時のプロジェクト ID


以前に watsonx.ai の REST API を紹介した記事を書きました。Watson Studio や Watson Machine Learning を有効にする手順や、API キー、プロジェクト ID を取得する手順についてはこの記事内でも紹介しているので必要に応じて参照してください:
https://dotnsf.blog.jp/archives/1082246988.html



【アプリケーションのセットアップ】
では実際に英会話アプリケーションを使うためのセットアップを行います。まずはコマンドプロンプトやターミナルを開いて、git でソースコードをクローン(ダウンロード)します:
$ git clone https://github.com/dotnsf/wwx

ダウンロードしたソースコードの依存ライブラリをインストールします:
$ cd wwx

$ npm install

テキストエディタで .env ファイルを開き、あらかじめ用意してある API キープロジェクト ID の内容をそれぞれ以下の場所に(コピー&ペーストするなどして)記述し保存します。他の部分は変更不要です。イコール記号の前後や行頭・行末に余計な空白などが含まれてないように注意してください:
WATSONX_API_KEY=(API キーの値)
WATSONX_PROJECT_ID=(プロジェクト ID の値)
WATSONX_MODEL_ID=meta-llama/llama-2-70b-chat
WATSONX_INSTRUCTION=[INST] <<sys>>\n ....(略)
WATSONX_PRE_INPUT=[User] 
WATSONX_POST_INPUT= \n[/User]\n

最後にアプリケーションを実行します:
$ node app

server starting on 8080 ...

"server starting on 8080 ..." と表示されていれば、アプリケーションの実行ができています。



【英会話アプリを実行する】
では実際に英会話アプリを実行してみましょう。Chrome ウェブブラウザ(WebAudio API を使うため、FireFox は NG)を起動し、"http://localhost:8080/" にアクセスしてみましょう:
2023112903


↑上のような画面が表示されると思います(途中でマイク許可のダイアログが表示されたら「許可」してください)。この画面を使って英会話を練習します:
2023112904


女性の口の部分がマイクのスイッチになっています(最初は青いボタンです)。ここをクリックするとマイクがオンになるので、マイクオンの状態で英語で話しかけてみてください:
2023113001


話し終わってある程度の無入力時間が経過すると、それまでに話した内容を WebAudio API が英語で解釈し、その解釈結果が画面に表示されます:
2023113004
(↑ "Hello. May I have your name, please?" と聞いてみました)


同時に解釈した結果(を英会話向けにプロンプトで加工したもの)を watsonx.ai が読み取り、英会話として続くような内容を返し、最後に WebSpeech API を使って英語で答えてくれます:
2023113002
(↑ちゃんと名前を教えてくれ、続けて私の名前を聞かれました。ちゃんと英会話として成立しています)


同様にして再度マイクオンにしてからマイクに英語で話しかけると英語で答えてくれる、という作業を繰り返すことができます:
2023113003

(↑「キムラ」が「キモラ」になってしまいましたが、これは私の英語発音が良すぎたせいで・・・ということにしておきます)


ぼっちでも英会話練習がはかどりますね。
この仕組みの内部的な挙動を少し説明しておきます。watsonx.ai ではいくつかの基盤モデルと呼ばれる大規模言語モデルが提供されているのですが、今回はその中からメタ社が提供している Llama2 という言語モデルを採用しています(後述のプロンプト含めていくつか試してみた上で、自然な英会話を成立させることができそうだ、と判断しました)。

また音声入力のテキスト化と、生成 AI による出力結果であるテキストの音声出力については Chrome ブラウザの WebAudio API を使っています。この部分についてはサーバーサイド型の仕組みを使うとタイムラグが大きくなってしまうため、ブラウザ内で実装できる方法を検討した結果です。とはいえ、聞き取り精度も、話す際の自然さも悪くないと感じています。一方でブラウザ毎の WebAudio API の実装に伴う制約などがあった場合にその制約を受けることにもなります(例えば FireFox では動かない、など)。

ちなみに Chrome WebAudio API 自体は日本語にも対応していて、日本語でも聞き取り&しゃべりどちらも高い精度でした。別の機会に使えそうでした。

そして肝心のプロンプトです。言葉で発した音声をテキスト化し、それをそのまま生成 AI へ・・・というわけではありません。実は上で .env ファイルを編集した時の4行目にこのような長い一行がありました:
WATSONX_API_KEY=(API キーの値)
WATSONX_PROJECT_ID=(プロジェクト ID の値)
WATSONX_MODEL_ID=meta-llama/llama-2-70b-chat
WATSONX_INSTRUCTION=[INST] <<sys>>\nYou are a friendly, respectful and honest friend. Always answer as friendly as possible, while being safe. Your answers should not include any harmful, unethical, racist, sexist, toxic, dangerous, or illegal content. Please ensure that your responses are socially unbiased and positive in nature. Also try to ask something for me, not try to help me.\n\nIf a question does not make any sense, or is not factually coherent, tell that you don't understand that well. If you don't know the answer to a question, please don't share false information.\n\nYou are an English speaker.\nIf you are asked a question in Japanese, please answer that you don't understand Japanese, and if you are asked a question in English, please answer in English.\n\nThe user is at beginner level of English, so please answer in easy and short English sentence as much as possible. Also you don't speak so much.\n\n<</SYS>>\n[/INST]\n
WATSONX_PRE_INPUT=[User] 
WATSONX_POST_INPUT= \n[/User]\n

詳しくはソースコード内を参照していただきたいのですが、実はこの4行目がプロンプトの指示部分になっていて、音声入力したテキスト部分に先立って入力されています。その内容を日本語で書くと、このような内容です:
あなたは優しく正直な友人です。可能な限りフレンドリーに回答してください。
でも相手を傷つけたり攻撃するような言葉は使わないでください。

・・(中略)・・

あなたは英語を話します。日本語で質問されても英語で「日本語はわからない」と答えてください。
英語で質問されたら簡単な英語で端的に答えてください。・・

このインストラクションに続いて、実際に喋った内容が英語で続けられたテキストが生成 AI に渡されています。このインストラクション部分がプロンプトに含まれていることで、(比較的初級の)英会話
アプリとして動くようになっています。

つまりこの .env の4行目部分を更にカスタマイズすることで英会話としての挙動を変えることもできると思っています(この部分が長くなる場合は現在 100 で固定している max_token 長も変更の必要があるかもしれません)。まあそのあたりに関しては(プロンプトエンジニアリングの楽しい部分でもあるので)ぜひ皆さんで試してみてください。 ・・といいつつ、実は私もこの辺りは同僚の後輩に手伝ってもらってたりするんですけど(笑)。


このブログエントリは Oracle Cloud Infrastructure アドベントカレンダー 2023 に参加しています。シリーズ2の 12/03 ぶんの記事です(↓の記事内容は 2023 年 12 月 02 日時点の内容で記載しています):
2023120101


このブログで紹介するテーマは「プライベート heroku っぽい PaaS 環境を無料で作る」というものです。個人プログラマにとって無料で使える環境というのはありがたいもので、Oracle Cloud Infrastructure(OCI) の always free tier は本当に貴重な環境であります。この環境に加えて、
sslip.io
dokku
という2つの無料サービス+無料アプリを使って、自分専用の heroku っぽい PaaS 環境を構築する(ついでに GitHub Actions も併用して CI/CD 環境も用意する)、というものです。


【heroku っぽい環境】
そもそも「heroku っぽい PaaS 環境」の「heroku っぽい」 環境というのがどんな環境なのか、heroku をご存じな人であれば heroku そのものをイメージしていただくのがいいと思うのですが、ご存じない人向けに私の理解で簡単に紹介しておきます。

heroku は 2007 年に創業したクラウドサービス事業名であり、その運営会社名です。Java, JavaScript, Ruby, PHP といった各種プログラミング言語によって開発されたウェブアプリケーションを仮想コンテナ上で実行するプラットフォームを提供しています。ウェブアプリケーションに加えて各種データベースや管理・監視系サービスも併せて利用することができます。最大の特徴は PaaS(Platform as a Services) を体現するシンプルさで、手元にアプリケーションのソースコードがあれば(あるいは GitHub などのソースコードリポジトリがあれば)コマンド1つでクラウド上にアプリケーションをデプロイ/更新/スケールすることができます。"*.herokuapp.com" というアプリ公開用ドメインが提供されていて、早い者勝ちでこのドメインを使ったホスト名によるアプリケーション公開ができます(カスタムドメインを利用することも可能です)。2010 年には買収に伴う形でセールスフォース・ドットコム傘下となり、セールスフォース連携機能も強化されました。

heroku は 2022 年 8 月までは無料枠を提供していましたが、現在は無料で利用することはできません。この記事で紹介する内容は、この heroku 環境を模した(というか、ほぼそのものの)「heroku っぽい」環境を自分専用に構築する、という内容です。


【sslip.io】
自分専用の heroku っぽい環境を作る、実はこれ自体は後述の dokku を使うことでさほど難しくなく構築することができます。問題は「無料で」の部分です。肝心のサーバーは OCI の always free tier などを使うことでなんとかなりそうなものですが、選択肢が少ないのが「アプリ公開用のドメインをどうするか?」でした。

この PaaS 環境はアプリケーションを1つだけ公開するわけではなく、リソースの許す限り(今回の例だと OCI の always free tier 環境の限界まで)使うことができます。つまり "app1.domain.net" と "app2.domain.net" と "app3.domain.net" と・・・のように複数のアプリケーションをこの環境で公開することができるのです。そこでこの "domain.net" に相当するドメインが問題になります。無料のホスト名を入手する DNS サービスなどもありますが、このような heroku っぽいプラットフォームを作ろうとすると、単に1つや2つのホスト名が使える DNS サービスでは不十分で、ワイルドカード DNS("*.domain.net" というワイルドカード付きホスト名に対応した DNS サービス)を、それも無料のものを使う必要があります。

そこで今回使うのが sslip.io です。ここは無料のワイルドカード DNS に対応した無料サービスなのですが、特筆すべきはその便利さです。事前のユーザー登録や設定なども不要(!)で、例えば
 XX.XX.XX.XX
というパブリック IPv4 アドレスであれば、
 XX.XX.XX.XX.sslip.io
という名前でアクセスできます。しかもワイルドカード DNS に対応しているので、
 *.XX.XX.XX.XX.sslip.io
というワイルドカードホスト名を(無料で)使うことができます。まさに今回のテーマのための神サービス! この sslip.io を使って PaaS 上にデプロイした各アプリケーションにホスト名でアクセスすることができるようになります。


【dokku】
そして dokku です。オープンソースの PaaS 環境は数あれど、構築や運用がここまで簡単なものは他にないと思っています(その代わり1サーバーで全環境を構築することになります。複数サーバーを束ねて PaaS 環境を作るのではなく、1サーバーだけのシンプルな構成で構築します)。

dokku 自体が「プライベート版 heroku」という代名詞で呼ばれることもあり、heroku と非常に似たインターフェースを持っています。dokku 本体が docker を内蔵しており、デプロイした各アプリケーションやデータベースは全て docker コンテナとして稼働します(docker コマンドを使って構築・運用するわけではありませんが、docker コマンドでコンテナの様子を確認したりもできます)。

また多くのプラグインも公開されており、例えば今回も使う Let's Encrypt プラグインを使うことでウェブアプリケーションを SSL 証明書に対応させることも(https アクセスに対応させることも)可能です。


これらのサービスやソフトウェアを組み合わせることで heroku っぽい PaaS 環境を無料で構築していきます。


【前提準備】
以下の構築作業に入る上での事前準備として、以下の準備までは完了しているものとします:

・OCI アカウント取得
Ubuntu ベースの always free tier インスタンスの作成
・同インスタンスへの SSH ログイン
・同インスタンスへの TCP 80 番ポート、および TCP 443 番ポートの解放

重要な点として、always free tier インスタンスは CentOS ベースではなく Ubuntu ベースのもの(デフォルトユーザー名が opc ではなく ubuntu のほう)にする必要があります。CentOS の場合、dokku の標準インストーラーではインストールすることができません。なんか色々工夫することで dokku インストール自体はできるようになるかもしれませんが、インストール後の運用手順とかにも影響ありそうなので、今回の記事では対象外とします。

また今回の作業の最終結果として構築する PaaS 環境ではデプロイされたウェブアプリケーションに 80 番(http)および 443 番(https)ポートで接続することになります。したがって Ubuntu 側もこれらのポートにアクセスされる想定でファイアウォールを準備しておく必要があるのでした(デフォルトでは OCI のサブネットと Ubuntu の ufw の両方が閉じられています)。この辺りはこちらの記事が参考になると思います(私も参考にさせていただきました):
Oracle Cloud(OCI)でポートを開放する方法


前提準備がそろった所で dokku を使ったプライベート PaaS 環境の構築を行います。


【dokku 環境構築作業手順】
SSH で対象の Ubuntu サーバーにログインします。以下、しばらく CLI での作業になります。とりあえずお約束のリポジトリ更新コマンドを実行しておきます:
$ sudo apt update -y && sudo apt upgrade -y

dokku はインストール用のスクリプトが提供されているので最初にダウンロードします:
$ wget -NP . https://dokku.com/bootstrap.sh

sudo を付けてインストールスクリプトを実行します。その際にバージョン番号(2023/12/02 時点での最新バージョンは v0.32.3)を指定します:
$ sudo DOKKU_TAG=v0.32.3 bash bootstrap.sh

このインストール作業は結構時間がかかります(docker のインストールや初期状態で必要なイメージのダウンロードも含みます)。作業が完了してプロンプトが戻るまでしばらく待ちます(ネットワーク次第ですが 10 分前後?)。

インストールが完了したら dokku にドメインを設定します。今回使う always free tier の Ubuntu サーバーのパブリック IP アドレスが XX.XX.XX.XX であったとして、以下のコマンドを実行します(XX.XX.XX.XX 部分を実際の IP アドレスに替えて実行してください):
$ dokku domains:set-global XX.XX.XX.XX.sslip.io

次に秘密鍵と公開鍵を dokku に登録します。dokku は dokku 自体が git リポジトリを内包していて、この内包 git リポジトリを使ってアプリケーションをデプロイするのですが、その際に利用する鍵ペアを登録する必要があるのでした。お持ちの鍵ペア(秘密鍵ファイルと公開鍵ファイル)があればそれを使うこともできるのですが、後述する CI/CD 機能のため「パスフレーズのない鍵ファイル」を用意することをお勧めします。ここではパスフレーズのない鍵ファイルを作成する手順を紹介します。

以下のコマンドを実行します。実行直後に作成する鍵ファイル名が表示されるので(特に変更の必要がなければ)そのまま Enter キーを押してください。次にパスフレーズの入力を求められますが、今回は何も指定せずにそのまま Enter キーを押してください(再度同じパスフレーズの入力を求められた時もそのまま Enter):
$ ssh-keygen -t rsa

Generating public/private rsa key pair.
Enter file in which to save the key (/home/ubuntu/.ssh/id_rsa): ←[Enter]キーを押す
Enter passphrase (empty for no passphrase): ←パスフレーズを入力せず [Enter] キーを押す
Enter same passphrase again: ←もう一度そのまま [Enter] キーを押す
Your identification has been saved in /home/ubuntu/.ssh/id_rsa.
Your public key has been saved in /home/ubuntu/.ssh/id_rsa.pub.

この操作が完了すると /home/ubuntu/.ssh/ フォルダ内に秘密鍵(id_rsa)と公開鍵(id_rsa.pub)の2つが作成されます。念のためパーミッションを 400 に変更しておきます(デフォルトでそうなっていると思いますが、念のため):
$ chmod 400 ~/.ssh/id_rsa*

これで dokku で使う秘密鍵ファイルと公開鍵ファイルが用意できました(既に所有していた鍵ファイルを使う場合はここから)。

まず以下のコマンドを実行して秘密鍵を登録します:
$ eval "$(ssh-agent)"

$ ssh-add -k ~/.ssh/id_rsa

続いて以下のコマンドを実行して公開鍵を dokku に登録します:
$ cat ~/.ssh/id_rsa.pub | sudo dokku ssh-keys:add admin

dokku 自体の環境構築はこれでほぼ完了していますが、ついでに前述の Let's Encrypt プラグインも導入しておきます。以下のコマンドを実行してください(2つ目のコマンドは Let's Encrypt で SSL 鍵を作る際に必要なメールアドレスの指定です):
$ sudo dokku plugin:install https://github.com/dokku/dokku-letsencrypt.git

$ dokku config:set --global DOKKU_LETSENCRYPT_EMAIL=(自分のメールアドレス)

ここまでの作業で dokku のインストール/セットアップは完了しました。


【アプリケーションをデプロイ】
dokku の動作確認の意味も含めて、この状態で一度ウェブアプリケーションをデプロイしてみましょう。自分で使ってみたいアプリケーションがあればそれを使っていただいてもいい※のですが、特にない方は私のアプリを使ってみてください(一応 Node.js で作られていますが、単にメッセージを表示するだけの本当にシンプルなウェブアプリケーションです):
https://github.com/dotnsf/simpleweb


※自作のものなど、他のアプリケーションを使っても構いませんが、この段階ではデータベースなどを併用しないものを用意してください。


まずはアプリケーションのソースコードを入手します。改めて Ubuntu の CLI で以下を実行してソースコードを git clone してください(自分のソースコードを使う場合は自分のコードの URL を指定してください):
$ cd

$ git clone https://github.com/dotnsf/simpleweb

$ cd simpleweb

このローカルリポジトリに dokku 用のオリジンを追加します(localhost の git を使って dokku という名前で simpleweb のオリジンを作成しています):
$ git remote add dokku dokku@localhost:simpleweb

このオリジンからデプロイする先のアプリケーション(simpleweb)を dokku 環境にあらかじめ追加しておきます:
$ dokku apps:create simpleweb

では実際にアプリケーションをデプロイします。git コマンドで作成したオリジンに push することで dokku 内へのデプロイが実行される仕組みが提供されているので、デプロイ作業は単に目的のオリジン(dokku)へ main ブランチを git push するだけです(秘密鍵にパスフレーズが指定されている場合はここで入力を促されるので正しいパスフレーズを入力してください):
$ git push dokku main

git push 実行後にデプロイが開始されます。ここではソースコードの内容からプラットフォーム環境(この simpleweb アプリの場合は Node.js 環境)を特定し、このプラットフォーム向けに docker コンテナイメージが作成され、そのイメージが dokku 内の docker コンテナとしてデプロイされることで完了します(というわけで、他のコマンドと比較して長い時間(約1~2分)がかかります):
2023120102


無事にデプロイが完了したら、最後にポート番号のプロクシ設定をします。この simpleweb アプリケーションは 8080 番ポートで稼働するよう作られているので、80 番ポートから 8080 番ポートに転送してアクセスできるよう設定します:
$ dokku proxy:ports-add simpleweb http:80:8080

2023120103


これで simpleweb アプリケーションが 80 番ポートで公開されているはずです。ウェブブラウザを起動して、
  http://simpleweb.XX.XX.XX.XX.sslip.io/
にアクセス("XX.XX.XX.XX" は Ubuntu サーバーの IP アドレス)し、以下のような画面になることを確認してください。このような画面が表示されれば成功です。dokku が正しくインストールされ、アプリケーションもデプロイできて、sslip.io を使ったホスト名での(HTTP による)アプリケーションアクセスができることが確認できました:
2023120104


ついでに HTTPS アクセスを有効にしましょう。コマンドラインで以下のコマンドを実行します:
$ dokku letsencrypt:set simpleweb email (自分のメールアドレス)

$ dokku letsencrypt:enable simpleweb

2023120105


ここまで正しく実行した上でウェブブラウザをリロードすると(TSHS が有効になっているので)自動的に HTTP から HTTPS に切り替わり、https アクセスで同じ画面が表示できるはずです(もちろん直接 https://simpleweb.XX.XX.XX.XX.sslip.io/ にアクセスしても同じ結果になります):
2023120106


【CI/CD】
最後に Github Actions を使った CI/CD 環境の構築手順についても紹介しておきます。GitHub でバージョン管理しているソースコードが更新されたタイミングで自動的に dokku 上のアプリケーションを最新ソースコードのアプリケーションに更新する、というものです。

まずは GitHub 上に管理対象のソースコードリポジトリを用意します。プライベートリポジトリでもパブリックリポジトリでもどちらでも構いません。以下では https://github.com/dotnsf/web-app-test というリポジトリを使った例を紹介しますが、実際に試す場合は実際の(自分が開発者としてアクセスできる)リポジトリを使ってください:
https://github.com/dotnsf/web-app-test


そしてそのリポジトリを使って上述のアプリケーションデプロイ手順を実行しておきます。つまり一度手動でオリジンを追加(git add remote origin)して、アプリケーションを作成(dokku apps:create)して、デプロイ(git push dokku main)して、HTTPS アクセスを有効(dokku letsencrypt:enable)にしておき、アプリケーションに HTTPS アクセスができる状態を作っておきます:
2023120105


この状態から CI/CD 環境を構築していきます。まずは GitHub Actions を定義するのですが、手始めに秘密鍵を登録します。GitHub の該当リポジトリで "Settings" メニューを選択します:
2023120107


"Settings" 画面の左メニューから "Secrets and variables" - "Actions" を選択します:
2023120108


秘密鍵を登録したいので "Secrets and variables" 画面内の "New repository secret" ボタンをクリックします:
2023120101


秘密鍵を登録する画面が表示されます。NAME 欄には SSH_PRIVATE_KEY と入力します。また value 欄には上述の作業中に作成して使った秘密鍵ファイル(id_rsa)のテキスト内容を "-----BEGIN RSA PRIVATE KEY-----" から "-----END RSA PRIVATE KEY-----" まで)をそのままコピー&ペーストして入力し、最後に "Add secret" ボタンをクリックします:
2023120102


以下のように表示されれば、GitHub リポジトリへの秘密鍵の登録は完了です:
2023120103


次にリポジトリ内にプッシュ時に実行される GitHub Actions を定義します。そのためにソースコード内に .github/workflows/ というフォルダを作成し、その中に deploy.yml(つまり .github/workflows/deploy.yml ファイル)を以下の内容で作成します:
---
name: 'deploy'

# yamllint disable-line rule:truthy
on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Cloning repo
        uses: actions/checkout@v2
        with:
          fetch-depth: 0

      - name: Push to dokku
        uses: dokku/github-action@master
        with:
          # specify the `main` branch as the remote branch to push to
          branch: 'main'
          git_remote_url: 'ssh://dokku@XX.XX.XX.XX:22/web-app-test'
          ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}

青字XX.XX.XX.XX は Ubuntu サーバーのパブリック IP アドレスで、赤字web-app-test 部分は(dokku apps:create した時に指定した)アプリケーション名で、それぞれ置き換えて作成してください。またソースコードの書き換えが視覚的にもわかるよう、アプリケーション側にも何らかの変更を加えておいてください。

これらのファイルをリポジトリに追加して更新します:
$ git add .

$ git commit -m 'dokku CI/CD with Github Actions'

$ git push

最後の "git push" が完了すると上記で作成した Github Actions が起動し、定義内容に従って自動的にビルド/デプロイが開始されます。デプロイの様子は Github リポジトリの "Actions" メニューから確認することが可能です:
2023120104


デプロイ完了後にアプリケーションにアクセスすると、最新のソースコードでデプロイされたアプリケーションが表示されるはずです(元のアプリで表示されていたメッセージの最後に "!" を追加する変更を加えていたのですが、正しく反映されていました):
2023120106


これで CI/CD も実現できたことになります。ソースコードの main ブランチが更新されてコミット/プッシュされるたびにアプリケーションも自動更新されてデプロイしなおされる、という便利な機能も使うことができそうでした。


【おまけ】
自分が dokku を使っていて気付いたことなのですが、どうもデプロイのたびに変なゴミというか、使われることのないファイルが残ってしまうらしく、特に CI/CD を使って何度もデプロイしていると、どんどんディスクの残り容量が減っていってしまうようでした。これを回避するには定期的に以下のコマンドを実行する必要があります:
$ docker system prune -f && docker system prune --all -f

面倒でなければ手入力でもいいと思いますし、面倒な場合は crontab にでも登録してアクセス数が比較的少なそうな時間帯に実行するのがいいと思っています。


【まとめ】
以上、無料のサービスだけを組み合わせる形で、CI/CD 含めたプライベート heroku (っぽい)環境を作ることができました。理論上はこの環境にいくつでも複数のアプリケーションを登録して、別々に運用することができる環境なのですが、さすがに always free tier の環境(メモリ 1GB ディスク 30GB)で運用することを考えると、5~6アプリが限界ではないかと思っています。もちろんこのリソーススペック上の制約は有償版を使うことで回避できます。また有償版の OCI なら DNS も使えるので(sslip.io を使う形ではなく)カスタムドメインを利用して環境構築することもできるようになります。 とはいえ、「無料で構築できる PaaS 環境」という素敵な響きにも替え難い魅力がありますよね。いずれはリッチなスペックの VM +カスタムドメインで運用するとしても、初めのうちはこの無料環境でプライベート PaaS を楽しむのも悪くないと思っています。なお(無料というわけにはいきませんが)カスタムドメインやその DNS のセットアップまで含める形で dokku 環境を作ったこともあります(OCI は全く使ってませんけど・・)。その時の内容に興味ある方は私の dokku 関連の過去の記事を参照ください。

dokku に関して、とりあえず環境構築と試験的な運用までが可能な一連の手続きについてはこのブログエントリ内で紹介していますが、実際にはこの中で紹介していない色々な機能があります(スケール対応とか、データベース系のプラグインを併用するとか、・・)。dokku はオンラインヘルプも充実しているので、詳しくはドキュメントを参照してください。

最後に注意点を。今回紹介した方法で dokku 環境を構築してアプリケーションを公開すると、事実上サーバーの IP アドレスを公開する形になります(sslip.io を使っているので、アプリケーションのホスト名の中に IP アドレスが含まれることになるからです)。サーバーの IP アドレスがバレても困らないように SSH などはしっかりセキュアに対策しておくといった対策はしっかりしておきましょう。

私自身は実際に dokku で(カスタムドメインを使って)自作サービスをいくつか運用しています。2年以上使っていますが、とても便利です。この記事が私と同じような技術クラスタの人のお役に立てれば何よりもうれしいです。


このページのトップヘ