【AWS】S3 Glacier Deep ArchiveにPCデータを安価にバックアップする

以前の記事で暗号化したHDDを東京から札幌の実家に数か月毎に持ち帰っていることを書きました。

kuranabe.hatenablog.com

この方法を数年続けており今のところ問題ありませんが、次のHDDの持ち帰りまでの数ヶ月間のブランクで災害が起きるとデータが失われる可能性があります。これとは別に毎日差分バックアップを取っていますが保存先が共に自宅内なので被災するとデータ消失のリスクがあります。

最近の自然災害の多さもあり、次のHDDの持ち帰りまでの間のデータをAmazon S3にバックアップする事にしました。

運用方針

物理HDD+AWSのハイブリッドで運用します。

  1. 暗号化したHDDを東京→札幌に持ち帰り
  2. 次回のHDDの持ち帰りまでの差分をS3にバックアップ
  3. (次回) 暗号化したHDDを東京→札幌に持ち帰り
  4. S3のオブジェクトを全削除
  5. 次回のHDDの持ち帰りまでの差分をS3にバックアップ
  6. …以下繰り返し

容量単価は物理HDDには適わないため、メインは従来通り物理HDDとし、S3はあくまで間の繋ぎとしての位置づけです。

なお、実家にSVを置いて拠点間バックアップも検討しましたが、電気代やトラブル時の遠隔メンテの困難もあり、物理HDD+クラウド方式が最良と判断しました。

構成の概要

以下の構成で作成します

  • Amazon S3 Glacier Deep Archiveに保存。リージョンはus-east-1
  • 既に運用中の差分バックアップスクリプトにS3アップロード処理を追加
  • アップロードファイルはAES256で暗号化
  • Multipart Uploadで100MBチャンクでS3にアップロード
  • ファイルの更新日時をメタデータで保持
  • バージョニングは行わない

バックアップのみで有事以外はダウンロードしないため安価なS3 Glacier Deep Archiveを用います。データ消失時の復旧速度は求めていないため、取り出し時は大容量取り出し(Bulk Retrieval)とする予定です。

また、災害目的のバックアップなため日本国外で自然災害が少なくコストの安い米国東部(us-east-1)を選択しています。イラン戦争でAWSの中東リージョンがデータ消失したニュース があるため戦争リスクを考えると米国リージョンも微妙かもしれません。地政学的にオーストラリアのリージョンが最も安全と考えてますがコストの関係で今回はus-east-1にしてます。本気でデータを守るなら地理的に離れたマルチリージョンが必須です。

S3はデフォルトで保存データが暗号化されますが、AWSのアクセスキー等の漏洩などを考慮してファイル自体をAES256で暗号化してからアップロードします。

コスト見積もり

次のHDD持ち帰りまでの増分データ数・ファイルサイズを見積もります。過去数年のファイルの増分を洗い出すと、6ヵ月平均で2000ファイル・50GBとなりました。

S3 Glacier Deep Archive・us-east-1の料金は以下です。

  • 保管料:1ヶ月で$0.00099/GB
    • 最低保存期間が180日間なので必ず最低6ヶ月分の料金が発生
  • PUT料:$0.05/1000ファイル
  • 取り出し料:大容量取り出しで$0.0025/GB

2000ファイル・50GBを6ヶ月間保存すると、

操作 料金
保管料(最低6か月) 46円
PUT料金 16円
取り出し料金 20円+α

※1ドル=155円

基本的に取り出しは発生しないため実質60円でバックアップ可能です。仮にファイル数・サイズが倍としても120円です。

4000ファイル・100GB

操作 料金
保管料(最低6か月) 92円
PUT料金 32円
取り出し料金 40円+α

厳密にはMultipart Uploadではファイル数ではなく各パートのアップロードリクエストで料金ががかかり、また更新日時を納めるメタデータ分のオーバヘッドもあるため若干高くなりますが、それでも非常に安いです。

あくまで次のHDDの持ち帰りまでの増分のみなのでこのコストで済んでいます。仮にフルバックアップすれば約4TBなので数千円になります。

S3バケットの作成

リージョンはus-east-1を選択

パブリックアクセスは全ブロックで、バージョニングは使いません。

後の項目もデフォルトのままでバケットを作成します。

バックアップデータのアップロード用のIAMユーザーを作成し、バケットポリシーでPutObject権限のみ与えます。GetObject権限はバックアップの復元が必要な時に与えます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Statement1",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::XXXXXXXXXXXXXXXXXXXXXXXXXXXX"
            },
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/*"
        }
    ]
}
アップロードするファイルの暗号化

AES-256・CBCでファイルを暗号化するクラスを作成します。エンコード処理ではファイルを100MiB毎のチャンクで暗号化してyieldを返し、呼び出し元でmultipart uploadします。巨大ファイルでもオンメモリで連続的に暗号化→アップロードが可能です。

デコードではS3からのダウンロードのStreamingBodyを受け取り、100MiB毎にデコードします。

from collections.abc import Iterator

from botocore.response import StreamingBody
from Crypto.Cipher import AES
from Crypto.Util import Padding

class Crypter:
    blocksize = 16

    def __init__(self) -> None:
        self.key = Padding.pad("xxxxxxxx", 32, "pkcs7")
        self.iv = b"xxxxxxxxxxxxxxxx"
        self.chunk_size = 1024 * 1024 * 100  # 100MiB

    def encode(self, srcpath: str) -> Iterator[bytes]:
        """ファイルを暗号化してchunkをyieldで返す

        Args:
            srcpath (str): ファイルパス

        Yields:
            Iterator[bytes]: 暗号化したchunk
        """
        aes = AES.new(self.key, AES.MODE_CBC, self.iv)
        with open(srcpath, mode="rb") as f:
            while chunk := f.read(self.chunk_size):
                chunk = Padding.pad(chunk, Crypter.blocksize, "pkcs7")
                yield aes.encrypt(chunk)

    def decode(self, body: StreamingBody, destpath: str) -> None:
        """s3.get_object()のstreaming bodyを復号化してファイルに書き込む

        Args:
            body (StreamingBody): s3.get_object()のstreaming body
            destpath (str): 復号ファイルのパス
        """
        aes = AES.new(self.key, AES.MODE_CBC, self.iv)
        with open(destpath, mode="wb") as fw:
            while chunk := body.read(self.chunk_size + Crypter.blocksize):
                print(len(chunk))
                decoded = aes.decrypt(chunk)
                decoded = Padding.unpad(decoded, Crypter.blocksize, "pkcs7")
                fw.write(decoded)

説明のために暗号keyやivはソース埋め込みですが、実際にはSSMのパラメータストアから取得しています。

暗号化したchunkをmultipartでS3にアップロード

先ほど暗号化したデータのchunkをマルチパートアップロードします。そのままではファイルの更新日時が保持されないためメタデータとして付与します。StorageClassの指定もここで行います。

def upload_file(filepath: str):
    client = boto3.client("s3", region_name="us-east-1")
    crypter = Crypter()
    mtime = os.stat(filepath).st_mtime
    try:
        create_response = client.create_multipart_upload(
            Bucket="bucket_name",
            Key="XXXX",
            Metadata={"mtime": str(mtime)},  # 更新日時をメタデータに入れる
            StorageClass="DEEP_ARCHIVE"
        )
        parts = []
        # チャンク区切りで暗号化してmultipart upload
        for i, encoded_chunk in enumerate(crypter.encode(filepath), start=1):
            response = client.upload_part(
                Bucket="bucket_name",
                Key="XXXX",
                UploadId=create_response["UploadId"],
                PartNumber=i,
                Body=encoded_chunk,
            )
            parts.append({"ETag": response["ETag"], "PartNumber": i})

        client.complete_multipart_upload(
            Bucket="bucket_name",
            Key="XXXX",
            UploadId=create_response["UploadId"],
            MultipartUpload={"Parts": parts},
        )

    except Exception as e:
        # 失敗時はabort
        response = client.abort_multipart_upload(
            Bucket="bucket_name",
            Key="XXXX",
            UploadId=create_response["UploadId"],
        )

S3からの復元処理

復元処理ではget_object()で返されるStreamingBodyを受け取り、100MiB毎に復号化します。ファイル生成後に元の更新日時を再セットします。

def download_file(filepath: str):
    response = client.get_object(
        Bucket="bucket_name",
        Key="XXXX",
    )

    crypter = Crypter()
    crypter.decode(response["Body"], filepath)
    mtime = float(response["Metadata"]["mtime"])
    os.utime(filepath, (mtime, mtime))  # 更新日時を再セット
バックアップ対象の検知

バックアップ元・先のローカルディスクをチェックし、バックアップ先に無い新規ファイルやファイルの更新日時・サイズが異なるファイルをコピーします。バージョニングはせず、削除同期も行いません。

コピー対象のファイルは先にS3のアップロード処理に流します。アップロードが成功したらローカルディスク間のコピーを行い、失敗したらローカルディスクのコピーを行いません。これにより次回のバックアップ時に再度アップロード対象として検知されます。

ソースは割愛します。

運用してみて

運用開始から7日が経ち、1300ファイル・28GBがアップロードされました。

コストですが、初回のアップロードで大量の同期が走りますが、それ以降の毎日の増分は少なくほぼ0ドルです。S3よりKMSの方がコストが掛かっていますね。。

本質のストレージよりコストが掛かるのは解せないので、クライアント側暗号のキー管理でKMSを使わない方法も検討したいです。

さて、東京+札幌/アメリカという実質的なマルチリージョンでバックアップする事ができました。あとは復元が必要な時に無事に復旧できるかが肝ですが、そのような事態が起きないことを祈るのみです。(そもそも私が災害等で死んでしまうとデータ復元も元も子もないので…)

個人開発ソフトがドイツ博物館の書籍に掲載されました

個人開発しているソフトがドイツ博物館の書籍「DM Studies 17 Digitising Piano Rolls」に掲載されました。

www.deutsches-museum.de

この本はピアノロールのデジタル化の歴史・手法について170頁に渡り詳細に記されています。私の取り組みは以下4項目で言及されていました。

  • 動画撮影によるロールの読取
  • リプロデューシングロールのエミュレーション
  • 手動での演奏の解釈
  • AIによる演奏の解釈

後半にはPeter Phillips氏やWayne Stahnke氏などの業界の第一人者による学術的な執筆も豊富で、非常に勉強になる一冊です。

著者の方とは3年前から様々なやり取りを重ね今回の掲載に至りました。

PDF版は無料でダウンロード可能で製本版は35ユーロとの事です。折角なので記念に製本版が欲しくなります。

Python yfinanceとAWS Lambdaで株価取得のAPIを作る

指定した株価になるとLINEに通知するGASを作成しているのですが、株価を手軽に取得できるAPIが無いため、株情報を無料で取得できるPythonのyfinanceとAWS LambdaでAPIを自作します。

Python yfinanceでの株価取得

yfinance単体での株価取得例です。ここでは株価・PBR・PER・配当利回りを取得しています。

import yfinance as yf

stock = yf.Ticker("285A.T")  # 日本株は末尾に.Tを追加
price = stock.info.get("currentPrice")  # 現在価格
pbr = stock.info.get("priceToBook")  # PBR
per = stock.info.get("trailingPE")  # PER
div = stock.info.get("dividendYield")  # 配当利回り

これをベースにAWS LambdaでAPI化します。

AWS LambdaでAPI化

以下の手順でAPIを作成します。

  • 関数URLを有効にしてLambdaを作成
  • yfinanceのパッケージをLambdaに適用
    • CloudShellでyfinanceのPythonパッケージをzip化
    • Amazon S3にパッケージzipをアップロード→Lambdaに適用
Lambdaを作成

Python3.13のランタイムでLambdaを作成します。

外部から呼び出すために関数 URL を有効にします。認証タイプはNoneを指定。

サンプルコードと共に関数が作成されました。

yfinanceで株情報を返すコードを作成します。株番号はURLのクエリパラメータで渡します。

>lambda_function.py

import json
import yfinance as yf

def lambda_handler(event, context):
    try:
        stock_no = event.get("queryStringParameters", {}).get("stock_no")
        stock = yf.Ticker(stock_no)
        return {
            "statusCode": 200,
            "body": json.dumps({
                "Price": stock.info.get("currentPrice"),  # 現在価格,
                "PBR": stock.info.get("priceToBook"),  # PBR
                "PER": stock.info.get("trailingPE"),  # PER
                "Rimawari": stock.info.get("dividendYield")  # 配当利回り
            })
        }
    except Exception as e:
        raise Exception(str(e))

CloudShellでパッケージをzip化

Lambdaではpip installできないため、別環境で関連パッケージ類を抽出しzip化してアップロードする必要があります。

今回は適用先のLambdaと同じアーキテクチャのCloudShellで行います。

まず、Lambdaの環境と同じpython3.13をCloudShellにインストールします。

sudo yum install python3.13 python3.13-pip -y

次にpackagesディレクトリを作成してyfinanceのパッケージを入れます。

mkdir packages
pip3.13 install -t packages yfinance

packages/の中身は以下の様になります。pandasやnumpyにも依存してるとは…。

次にアクション→ファイルのアップロードより先ほど作成したlambda_function.pyをアップロードします。

~ $ ll
-rw-r--r--.  1 cloudshell-user cloudshell-user  649 Apr  5 05:39 lambda_function.py
drwxr-xr-x. 49 cloudshell-user cloudshell-user 4096 Apr  5 05:31 packages

zipに固めます。

cd packages/
zip -r ../packages.zip .

cd ..
zip packages.zip lambda_function.py 

出来上がったpackages.zipはアクション→ファイルのダウンロードよりDLします。

参考:出来上がったzipの構成。lambda_function.pyとyfinanceの関連パッケージが入っています。

S3にパッケージのzipをアップロード

zip容量が10MB未満ならLambdaで直接アップロード可能ですが今回は53MBと大きいためS3経由でアップロードする必要があります。

アップロードしたS3 URIをコピーしておきます。なお、後述のLambdaへのアップロード後はオブジェクトは不要になるので削除して大丈夫です。

Lambdaにパッケージのzipをアップロード

作成したLambdaのアップロード元→Amazon S3の場所より、パッケージzipのS3 URIを指定します。

保存を押して完了です。先ほど作成したS3のオブジェクトは削除してOKです。

APIエンドポイントのクエリパラメータに銘柄コードを指定して動作確認します。

MU (マイクロン)

>curl https://XXXXXXXXXXXXX.lambda-url.ap-northeast-1.on.aws/?stock_no=MU
{"Price": 366.24, "PBR": 5.701387, "PER": 17.267326, "Rimawari": 0.16}

3048.T (ビックカメラ)

>curl https://XXXXXXXXXXXXX.lambda-url.ap-northeast-1.on.aws/?stock_no=3048.T
{"Price": 1785.5, "PBR": 1.7940378, "PER": 17.51177, "Rimawari": 2.33}

正しく動作していますね。これで完成です。

2025年振り返り

気づけば2025年終わりですね。最近仕事が忙しすぎて年の瀬感が全くありません。

個人開発

今年も細々と個人開発を続け、既存ソフトの改良や新規ソフトのリリース、雑誌掲載などがありました。

GitHub - nai-kon/PlaySK-Piano-Roll-Reader: Convert Piano Roll Image to MIDI in real-time

メインで開発しているソフトで、今年は要望の多かったAmpico Aのロールに対応しました。Ampico Bに比べ制御パラメタが複雑に絡み合うため調査など含めて実装に苦労しましたが、結果的に満足の行くエミュレーションとなりました。

これで大半のフォーマットは対応済となり更新は落ち着きつつありますが、マイナーなロールへの対応要望がまだあるため牛歩で対応中です。

また、ドイツの業界紙にソフトの紹介記事が載りました。著者の方とは約2年前からやり取りをしており出版を楽しみにしていました。雑誌掲載は初めてで良い経験になりました。個人開発ソフトがドイツの雑誌に掲載されました - nai-konの開発備忘録

この他にも掲載が予定されているため、掲載されたら記事にしたいと思います。

GitHub - nai-kon/PlaySK-Midi-PianoRoll-Image-Converter: Convert Midi file to Piano Roll Image for PlaySK Piano Roll Reader

こちらはアメリカのユーザーからの依頼で7月に新規開発したソフトで、MIDIを忠実なピアノロール画像に変換するものです。通常のMIDIの変換はもちろん、e-roll MIDIと呼ばれるピアノロールの制御孔をそのままMIDI化したファイルを変換すれば元のロールに忠実な画像を生成できます。

変換した画像は最初に紹介したエミュレーションソフトで再生できます。e-roll MIDIはライブラリが豊富なため再生できる曲の幅が広がりました。以下はe-roll MIDIを画像化して再生したドホナーニの自作自演の演奏です。

youtu.be現状は変換画像の出力のみですが、将来的にはG-Code形式のファイルを出力してCNCのレーザーカッターで実物の紙を削孔できる様にしたいと思っています。

個人開発の収益については昨年と同じ位の額でした。お陰様でインフラ費用はペイできています。来年も収益で経費をカバーできればと思います。

LLM

ChatGPTの登場から早3年ですが、文章生成、画像・動画生成、コーディングなど公私共に使い倒した1年でした。特に今年は画像・動画生成の勢いを感じる1年で、動画はSora、画像はNano Banana Proを多用しました。

コーディングではClineとGitHub Copilotを活用しており、0→1の立ち上げはCline、調整はCopilotを活用しています。今年の新規開発ソフトもClineで0→1の立ち上げを行い非常に効率化しました。

便利に活用していますが、「時間かけるならAIにやらせた方が早い」というマインドになり思考力や実装力の低下を懸念しています。効率化してますが、これで良いのだろうかという何とも言えない気持ちです。

 

さて来年は何だか大きな動きがある1年になりそうです。何より個人開発のタスクが山積みなので少しずつ消化せねば…。

 

個人開発ソフトがドイツの雑誌に掲載されました

個人開発しているソフトがドイツの業界雑誌「Das Mechanische Musikinstrument」に掲載されました。

www.musica-mechanica.de

このソフトは他にはないユニークなソフトで、アメリカを中心に世界各国にユーザーがいます。雑誌の編集者から約2年前にコンタクトがあり、ソフトウェアの仕様や動作原理など様々なやり取りを重ね今回の掲載に至りました。当初は私自身が執筆する提案でしたが最終的にはプロの方に執筆をお願いしました。

個人開発ソフトの雑誌掲載は初めてで、かねてからの目標であったため達成感を覚えます。

雑誌の編集者から許可を頂いたので該当ページを掲載します。全てドイツ語ですが雰囲気は分かるかと思います。

 

さて、12月には別の雑誌の掲載も予定されています。掲載され次第ご報告します。

画像分類アプリのVLMをBLIP2→Qwen3-VLに更新

約3年前からXのタイムライン画像をVLMで分類するWebアプリを運用中です。雑多なタイムラインの画像を自分の好きな分類でフィルタリングできるので確認が捗ります。

以下は「食べ物」「猫」「犬」「その他」で分類する例です。

SalesforceのBLIP-2を用いて入力画像に含まれる物体をVQAしています。上の例では3件の質問をそれぞれVQAしています。
* Do you see any foods in the picture?
* Do you see any cats in the picture?
* Do you see any dogs in the picture?

1回のpromptで3件まとめてVQAしたい所ですが、精度が出なかったため1prompt=1件としています。それをbatch_size=3で推論しています。

特に問題なく運用していましたが、ここ最近OCRやSegmentationができる高性能なVLMが続々登場しており、この機会にモデルを更新することに。

Qwen3-VLを試す

Qwen3-VLは10月にリリースされた最新のVLMです。VQAに加えOCRタスクやBBOX座標出力ができる優れモノで、モデルサイズは2B~235BA22Bまで幅広くあります。

huggingface.co

今回は4B, 8B, 30BA3BのGGUFモデルを選びました。モデルはllama-serverでホストし、画像はbase64でエンコードしてAPIに投げます。

llama-serverの起動

.\llama-server.exe -m Qwen3-VL-8B-Instruct-Q4_K_M.gguf --mmproj mmproj-Qwen3-VL-8B-Instruct-F16.gguf

VQAの実行例

img = Image.open("cat and dog.jpg")
img = resize_img(img, longest_edge=512)  # 長辺を指定pxでリサイズ

buffered = io.BytesIO()
img.save(buffered, format="BMP")
base64_image = base64.b64encode(buffered.getvalue()).decode('ascii')

prompt = """Answer following questions with only Yes/No. Separate by space.

* Do you see any foods?
* Do you see any cats?
* Do you see any dogs?
"""

messages = [{
    "role": "user",
    "content": [
        {
            "type": "image_url",
            "image_url": {
                    "url": f"data:image/bmp;base64,{base64_image}"
            },
        },
        {"type": "text", "text": prompt},
    ],
}]

model = OpenAI(base_url="http://localhost:8080", api_key="dummy-api-key")
completion = model.chat.completions.create(
    model="dummy-model",
    messages=messages,
    temperature=0.0,
)
print(completion.choices[0].message.content)  
# No Yes Yes

BLIP-2とは違い1promptで複数の質問を入れても精度が出ました。応答は「No Yes Yes」のように空白区切りで羅列して出力トークン数を減らします。

速度・精度

BLIP-2とQwen3の各モデルで精度・速度を比較します。

  • 特定の物体が含まれる正例画像50枚と、無関係の負例画像50枚を用意
  • 画像中に4つの分類が含まれるかで精度検証。分類の内訳は非公開
  • 検証マシンのスペックはCore i5 12600KF、RTX5060Ti 16GB、Win10 64bit
  • BLIP-2はhuggingfaceのtransformersで読込、Qwen3-VLはllama-serverで読込。
モデル 画像長辺px 正解率
正例50枚200項目
正解率
負例50枚200項目
推論時間 秒/枚
BLIP-2-flan-t5-xl FP16 - 53.5% 100.0% 0.17
         
Qwen3-VL-4B-Instruct Q4_K_M 256 84.5% 100.0% 0.10
  384 80.0% 100.0% 0.17
  512 83.0% 99.5% 0.21
  768 82.0% 100.0% 0.33
  1024 81.5% 100.0% 0.52
         
Qwen3-VL-8B-Instruct Q4_K_M 256 87.0% 99.5% 0.19
  384 89.5% 99.5% 0.22
  512 92.5% 100.0% 0.28
  768 93.0% 100.0% 0.48
  1024 90.5% 100.0% 0.84
         
Qwen3-VL-30B-A3B-Instruct Q4_K_M 256 91.0% 99.5% 1.40
※VRAMに乗り切らず遅 384 90.0% 100.0% 2.02
  512 91.0% 99.5% 2.79
  768 92.5% 100.0% 3.79
  1024 92.5% 100.0% 6.25

今回初めて定量評価しましたがBLIP-2が想定より低精度でした。実運用でも分類漏れは多少ありましたが数字で見ると中々です。

Qwen3-VLは、モデルサイズと精度が概ね比例し画像長辺サイズは512-768pxが最適となりました。正例と負例の精度に乖離がありますが、今回の分類には「物体の状態」の考慮が必要な分類があり、難易度が高くなっています。人間でも95%程が限界かもしれません。

 

実験結果より8B+長辺512pxを採用することにしました。早速アプリに組み込んで試していますが従来のBLIP-2に比べ圧倒的な精度向上を実感しています。もう少し早めに更新すれば良かったかも。

ゲストWiFiでSwitchBotのセグメントを分離

3年前からSwitchBotでエアコンや照明をスマホ操作しています。この時期はタイマーで暖房をセットして帰宅前には部屋が暖まる様にしており生活の必需品です。

普段使いのPCやスマホと同じネットワークにぶら下げてますが、中国製IoT機器を同一ネットワークで運用するのは危険との話もあるのでセグメントを分離することに。Swithbotの操作は自宅内からでも外部サーバー経由なのでネットワークを分離しても問題ありません。

セグメントの分離は当初はVLAN対応スイッチを検討しましたが使用中のJ:COMのモデムにゲストWiFi機能があるので代用します。

構成図

まず、モデムの管理画面からゲストAPのSSIDを新規作成して保存。

隔離モードは「すべて隔離」↓に設定します。

あとはSwithBotを新しいAPに接続して本体を再起動。

再設定後の接続端末一覧です。SwithBotだけ192.168.22の別セグメントに分離されています。

念のためAP間で通信が通らない事を確認して完了です。これで一安心ですね。

Appleのnotarytoolで「A required agreement is missing or has expired」のエラー

2か月ぶりにアプリを更新しようとGitHub Actionsでビルドを実行するとMac版アプリが公証でエラーになりました。

以下がエラー内容で、Apple Developer IDの契約が未同意か期限切れと言われています。

Error: HTTP status code: 403. A required agreement is missing or has expired. This request requires an in-effect agreement that has not been signed or has expired. Ensure your team has signed the necessary legal agreements and that they are not expired.

This process stores your credentials securely in the Keychain. You reference these credentials later using a profile name.

Validating your credentials...

今年4月にApple Developer IDの契約を更新したばかりで不思議に思いAppleにログインすると、いつの間にか使用許諾が更新され未同意の状態でした。


同意をして再度実行するとビルドが無事通りました。必要な時にCIが動かないと困るのでまめな確認が必要ですね。

さて、作成しているアプリに関する記事が今年の冬にドイツの業界誌 Das Mechanische Musikinstrument に掲載される事になりました。初めての雑誌掲載のため今から楽しみです。

llama-server上のgpt-ossのreasoning_effortを変更する

OpenAIのローカルLLM「gpt-oss」はOpenAI o1, o3と同様に思考レベル「reasoning_effort」を"low", "medium", "high"の3段階に動的変更できます。

システムプロンプトで以下の様に記述されます。デフォルトは"medium"です。

<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI.
Knowledge cutoff: 2024-06
Current date: 2025-08-07

Reasoning: medium

# Valid channels: analysis, commentary, final. Channel must be included for every message.<|end|>
openai-pythonライブラリからreasoning_effortを設定

llamacppのllama-serverでgpt-ossをホストしてopenai-pythonライブラリからreasoning_effortを設定します。

1. llama-serverを起動

llama-server -m gpt-oss-20b-mxfp4.gguf -c 0 -fa --jinja --reasoning-format none

localhost:8080でOpenAI互換のAPIサーバーが起動します。

2. openai-pythonライブラリ経由でリクエスト

from openai import OpenAI

model = OpenAI(base_url="http://localhost:8080", api_key="dummy-api-key")
completion = model.chat.completions.create(
    model="dummy-model",
    messages=[{"role": "user", "content": "9.11と9.9の大小を比較してください"}],
    extra_body={"chat_template_kwargs":{"reasoning_effort": "low"}},
    # reasoning_effort="low",  # これは無視される
)

print(completion.choices[0].message.content)

reasoning_effortはextra_body={"chat_template_kwargs":{"reasoning_effort": "high"}}のように指定します。

オリジナルのreasoning_effort引数に指定しても反映されないので注意。

各reasoningレベルの出力は以下です。

reasoning_effort: low

<|channel|>analysis<|message|>Need to compare 9.11 and 9.9. 9.9 > 9.11? Actually 9.9 is 9.90; 9.11 is 9.11. So 9.9 > 9.11.<|start|>assistant<|channel|>final<|message|>

9.9 は 9.11 より大きいです。
(9.9 は 9.90 とみなせ、9.11 は 9.11 ですので、9.90 > 9.11)

reasoning_effort: medium

<|channel|>analysis<|message|>User: "9.11と9.9の大小を比較してください" Means: Compare the sizes of 9.11 and 9.9. They likely refer to numeric values: 9.11 vs 9.9. So 9.9 is larger than 9.11. If we interpret as decimal numbers, 9.9 > 9.11. The difference is 0.79. 9.9 is about 8.7% greater. Provide explanation: compare integer parts: both 9, compare decimal parts: .9 > .11. So answer: 9.9 > 9.11.

Also could mention difference. So answer: 9.9 is larger. Provide details.<|start|>assistant<|channel|>final<|message|>

**9.9 は 9.11 より大きい**  

- まず整数部を見ます:両方とも「9」なので整数部は等しい。  
- 次に小数部を比較します:  
  - 9.11 の小数部は「.11」  
  - 9.9 の小数部は「.9」  

0.9 は 0.11 より大きいので、9.9 は 9.11 より大きいです。  
差は \(9.9 - 9.11 = 0.79\) です。

reasoning_effort: high

<|channel|>analysis<|message|>We need to compare 9.11 and 9.9. The user presumably is Japanese: "9.11と9.9の大小を比較してください" means "Compare the magnitudes of 9.11 and 9.9." So presumably they want to know which is larger: 9.11 vs 9.9. Obviously 9.9 is larger. But we might also explain decimal values. 9.11 is 9.11, 9.9 is 9.9 = 9.90. So 9.90 > 9.11. So answer: 9.9 > 9.11. Also can mention difference: 0.79. So 9.9 - 9.11 = 0.79. So 9.9 is greater. Provide explanation. Probably they want just simple answer. Provide in Japanese.

So answer: 9.9 > 9.11. The difference is 0.79. Or 9.9 is 9.90, which is 0.79 greater. So answer in Japanese.

Thus: 9.9 は 9.11 より大きい。 9.9 - 9.11 = 0.79. That is the comparison. Also we can talk about decimal places. But it's enough.

We can also mention that 9.11 is 9 + 0.11, while 9.9 is 9 + 0.90. So obviously 0.90 > 0.11. So 9.9 > 9.11.

Hence answer: 9.9 is larger. Provide that. Also can mention that 9.11 < 9.9.

Thus answer in Japanese. Let's produce concise answer.<|start|>assistant<|channel|>final<|message|>

9.11(9 + 0.11)と9.9(9 + 0.90)を比較すると、0.90 の方が 0.11 より大きいので 

**9.9 > 9.11** です。差は 9.9 − 9.11 = 0.79 になります。

設定に応じてreasoning量が変化しています。いずれの回答も正しいです。

※なお、私は9.9と9.11はバージョン番号に見えるタチなので9.11が大きいと判断してしまいます…。

MIDIをリアルなピアノロール画像にするソフトを作成

「MIDIからピアノロール画像を生成したい」という依頼がアメリカのユーザーからあり、ソフトをリリースしました。普段、ピアノロール画像をMIDIに変換するエミュレーターを開発していますが、それとは真逆なソフトです。

GitHub - nai-kon/PlaySK-Midi-PianoRoll-Image-Converter: Convert Midi file to Piano Roll Image for PlaySK Piano Roll Reader

MIDIファイルから音とそのタイミング(tick)を読み込み、設定したテンポやDPIから穴の描画位置・長さを計算してピアノロールの画像を生成します。

MIDIをピアノロール形式で表示するソフトは多数ありますが、このソフトはピアノロールの紙幅やパンチ穴のサイズ、ミシン目の様なパンチ穴など非常に忠実な見た目で画像を生成します。

変換した画像はピアノロールリーダーで再生したり、外部ソフトで画像→G-Codeに変換してレーザーカッターで本物のピアノロールを作成したりできます。画像をただ眺めても面白いです。

(変換の様子)

youtu.be

変換したサンプル画像 (クリックで全体表示)

主な機能
  • 通常のMIDIや、強弱等の制御孔をMIDI化したe-roll MIDIから画像を生成
    • 初版はマルチトラックや曲の途中でのテンポ変更には非対応
  • ロールのテンポ、画像のDPI、穴のサイズ・間隔、オフセットなどを設定可
  • ロールの加速補償に対応
    • ピアノロールは巻取速度が徐々に加速するため、加速の補償が必要
    • Stanford大学の論文より1フィート毎に0.2%加速補償するように設定 (調整可)
  •  88-Noteのトラッカーバーに対応
    • 今後、Ampico・Duo-Artなど拡大予定 
    • 現時点でもAmpico・Duo-Artのe-roll MIDIを変換できるが、穴のサイズ・オフセットは考慮されない
  • ダークモード・ライトモードを切替可
    • 個人的にダークモードは必須
コアロジック

コアロジックであるMIDIのtickを画像のピクセルに変換する計算式は以下です。

 ピクセル = tick * 出力DPI * ロールテンポ * 1.2 / BPM * PPQ

ピアノロールのテンポは"80"なら8フィート/分を意味します。ここでは1.2倍してインチに変換します。

これにロールの加速補償(0.2%/feet)を加えると以下になります。

ピクセル = ピクセル * (1.002 ^ (ピクセル / 出力DPI / 12))

開発言語・環境
  • Python、CustomTkinter、Pillow、mido
    • UIはモダンな見た目で人気のCustomTkinterを採用
    • 巨大画像を表示できるスクロールビュー等の独自widgetを作成
    • 画像はPillow、MIDIはmidoのライブラリを使用
  • GitHub、GitHub Actions、uv
    • ビルド・リリースを自動化
    • パッケージ管理はuvを使用
  • Cloudflare Workers + D1

当初はインストール不要で手軽に扱えるようWebアプリにする予定でしたが、画像高が数十万pxと巨大でブラウザで扱うには難があり、またインフラ費用の懸念もあるためデスクトップアプリになりました。結局、Python+CustomTkinterで書いてます。最初は勉強がてら普段書かないJavaで書いていましたが、画像描画周りに致命的な欠陥があったり描画速度がPythonより遅かったりで採用を取り止めました。

 

初版をリリースして3日程ですが、早速海外のユーザーから好評や寄付がありました。新しいロールフォーマットの対応など今後も継続してアップデート予定です。