megutech

自身の備忘録として主にWEBサーバー周りの技術について投稿しています。

X (Twitter) のIntent URL `/intent/tweet` vs `/intent/post`

WebからXへシェアする機能を実装したところ、Xアプリが起動 -> アプリ内ブラウザ起動 -> Safariへリダイレクト -> Xアプリ起動という無限ループに陥った。
調査の結果、Intent URLのエンドポイントによって挙動が異なることが判明したので備忘録として残す。

環境

発生した問題

Webサイトにシェアボタンを実装し、/intent/post を使用したところ以下の無限ループが発生:

  1. Xアプリが起動
  2. アプリ内ブラウザが起動
  3. アプリ内ブラウザがデフォルトブラウザ(Safari)を開こうとする
  4. SafariがXアプリを開こうとする
  5. 1に戻る

調査

Intent URLの種類

以下の2種類のエンドポイントが存在する:

https://x.com/intent/tweet
https://x.com/intent/post

挙動の違い

/intent/tweet

  • Xアプリがネイティブで起動
  • 投稿画面が直接開く
  • 正常動作

/intent/post

  • Xアプリは起動する
  • しかしアプリ内ブラウザが起動
  • そのブラウザ内で投稿画面が表示される
  • 無限ループの原因

公式ドキュメントの確認

X Developer Platform の公式ドキュメントを確認したところ、/intent/tweet のみが記載されている

/intent/post については記載がない。

対応

Intent URLの変更

- https://x.com/intent/post?text=...
+ https://x.com/intent/tweet?text=...

結論

  • 使用すべきは /intent/tweet
  • 公式ドキュメントに記載されている
  • 挙動が安定している

感想

ブランディングで「これからはPostだ」という方針だったので /intent/post を使っていたが、無限ループに陥って調査したところ、公式ドキュメントには記載すらされていなかった。
結局 /intent/tweet に戻すことで解決した。

公式ドキュメントは大事。

シャドーイングも声真似も全部これ1つ。音声分析×練習ループで劇的成長できる「Rekoe」

この度個人で開発している 声トレ/声まね練習アプリ『Rekoe』 を公開しました。
この記事では、Rekoeの概要、主な機能、使い方、開発の背景、そして今後のアップデート計画をまとめます。
初めての方でも使い始めやすいよう構成しています。

こんな人におすすめ

ダウンロード

iOS

Rekoe

Rekoe

  • Tsuyoshi Matsunaga
  • エンターテインメント
  • 無料
apps.apple.com

Android

play.google.com

主な機能

1)録音・比較再生

  • ファイルやiTunesiOS限定)から素材を取り込み、音源を聞きながらワンタップ録音。
  • 録音後簡単に音声比較が可能です。

2)スペクトラム表示 + ピッチ/フォルマント

  • Hz×dBのスペクトラムで特徴を可視化。ピッチ推移とフォルマントで“どこを直すか”が明確に。

3)ディレクトリ・ソート・メモ

  • 録音や素材を整理し、メモで管理。
  • 練習ログを残すことで成長が可視化できます。

4)速度調整(タイムストレッチ)

  • フレーズをゆっくり再生して聞き取り→定着。
  • 慣れたら再生速度を戻して実戦に近づけます。

使い方

STEP 1:目標を決める

好きな声・再現したい声のフレーズを用意します。

STEP 2:聞く → 真似る → 録る

音源を聞きながら録音。余計な間はカットすると比較が楽です。

STEP 3:波形・スペクトラム・ピッチで差分確認

  • タイミング: 元音声と同じ間の取り方ができているか
  • ピッチ:高すぎ・低すぎ/上下の安定感
  • スペクトラム:出てほしい帯域が十分か
  • フォルマント:山の位置関係(F1/F2/F3)

STEP 4:1つだけ直す

一度に全部直そうとせず、「今日はピッチを合わせる」など1テーマに絞るのがコツです。

価格と機能(現時点)

  • 基本:無料(広告あり)
  • 広告非表示(300円)
  • 機能拡張パック(800円)
  • コンプリートパック(1000円)

開発の背景(裏話)

ポーズクロッキーというアプリを試した際、お手本を上に表示し、下でクロッキー、指定秒数で自動でお手本とクロッキーを重ね合わせ比較され、そのまま次のクロッキーへ。 という体験がよすぎて、これの音声版欲しいなと思ったのがきっかけ。

ここで自分が本当に感動したのは「重ね合わせ」そのものではなく、クロッキー→自動重ね合わせ→すぐ次のクロッキーという“練習ループが勝手に回り続ける設計”だった。 面倒な段取りをアプリが肩代わりするから、ユーザーは練習だけに集中できる。この思想を音声練習に持ち込みたかった。

ただ音声比較の場合、重ね合わせだけだと負担が大きい。そこで、FFT・ピッチ推定・フォルマント近傍の山検出による可視化を組み合わせ、素材選択→録音→自動比較(波形・スペクトラム・ピッチ)→次テイクまでを短い導線で回せるようにした。短いループを高速に回せるから、上達の手応えが得やすい、はず。

よくある質問(FAQ)

Q. 歌やアニメのセリフでも練習できますか?

  1. 可能です。短いフレーズから始めると分析・改善がしやすくおすすめです。

Q. 録音データはどこに保存されますか?

  1. すべて端末内に保存され、ネットワークにアップロードされることはありません。

Q. どの帯域を見れば良いか分かりません。

  1. まずはピッチの安定→フォルマントの順がおすすめです。

Q. 推奨の練習時間は?

  1. 毎日10〜15分の短時間継続が効果的です。

Q. iTunesの楽曲はすべてインポートできますか?

  1. 原則、DRM保護のない楽曲が対象です。Apple MusicのストリーミングやDRM付き音源は取り込めません。

SvelteKitの遅延読み込み中にエラーが発生するとクラッシュする

昨日の記事で無事遅延読み込みは成功するようになりました。

megu-tech.hatenablog.com

しかし新たな問題として、この遅延読み込み処理中にエラーが発生すると、sveltekitがクラッシュすることがあるという現象に見舞われました。

環境

Service Version
@sveltejs/kit 2.5.28
Node.js 20.9.0

コード

遅延処理中に500エラーが発生するようにしています。

import type { PageServerLoad } from './$types';
import axios from 'axios';

export const load: PageServerLoad = async () => {
  return {
    data: axios.get('https://httpstat.us/500'),
  };
};

原因

https://kit.svelte.jp/docs/load#streaming-with-promises

レンダリングの開始時点 (本来この時点で catch される) より前に遅延ロード (lazy-loaded) された promise が失敗し、エラーを処理していない場合、server が "unhandled promise rejection" エラーでクラッシュする可能性があります。

公式ドキュメントに書いてありました。

対策

対策も公式ドキュメントに書いてありました。

fetchを使う

SvelteKit の fetchload 関数で直接使用する場合は SvelteKit がこのケースを処理してくれます。

Axios等を使っている場合、adapterとしてSvelteKitのfetchを使うようにしてあげるといいでしょう。

import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ fetch }) => {
  return {
    data: fetch('https://httpstat.us/500'),
  };
};

握りつぶす

それ以外の promise の場合は、何もしない catch (noop-catch) をアタッチし、処理済であることを明示するだけで十分です。

import type { PageServerLoad } from './$types';
import axios from 'axios';

export const load: PageServerLoad = async () => {
  return {
    data: axios.get('https://httpstat.us/500').catch(() => { error: true }),
  };
};

実施した対応

握りつぶした場合、せっかくフロントで遅延時のエラーを処理する仕組みがSvelteKitにあるにもかかわらず使用できないので、fetchを使う方法を採用しました。

感想

やはり公式ドキュメントしか勝たん。

Nginx環境でのSvelteKit遅延読み込み問題と解決策

開発環境ではSveltekitの遅延読み込みは問題なく動いていたのですが、Nginxを経由する本番環境では機能せず、ページの表示に時間がかかってしまうという現象にでくわしました。

環境

Service Version
Nginx 1.18.0
@sveltejs/kit 2.5.28
Node.js 20.9.0

コード

+page.server.ts

import type { PageServerLoad } from './$types';

const getResponse = async (): Promise<void> => {
  await new Promise((resolve) => setTimeout(resolve, 10000));
};

export const load: PageServerLoad = async () => {
  return {
    data: getResponse()
  };
};
<script lang="ts">
    import type { PageData } from './$types';
    export let data: PageData;
</script>

{#await data.data}
    loading...
{:then data}
    遅延読み込み成功
{:catch}
    エラーが発生しました
{/await}

原因

プロキシ (例えば NGINX) を使用している場合は、プロキシされたサーバーからのレスポンスをバッファしないようにしてください。

公式ドキュメントに書いていました。

対応

NginxでStreamをbufferしないよう、下記設定を追加しました。

proxy_buffering off;

感想

公式ドキュメントしか勝たん。

Sveltekitでファイルダウンロードをさせたい

sveltekitでPDFなどを返し、ダウンロードしてもらいたかったのだが、少しつまづいたので備忘録。

つまずきポイント

+page.server.tsloadでは対応できない

まず最初に試したのは、+page.server.tsloadでResponseを返すといった方法。

しかし+page.server.tsload+page.svelte表示前にサーバー側で処理を行うファイルであり、つまりページを表示するためのルートであるため、ここでResponseは返すことが出来ないようだ。

+page.server.tsactionsでは対応できない

次に試したのは、+page.server.tsactionsでResponseを返すといった方法。

ページ表示で対応できないのであれば、POSTで受け取ろうという発想。 しかしこれもまた+page.server.tsに記述することからわかる通り、POST処理後にはリダイレクトするか、+page.sveleteを表示するためのメソッド。 ここでResponseを返すことは出来なかった。

解決策

+server.tsを利用する

SveltekitのAPIエンドポイントである+servet.tsを利用することで、Responseを返すことが出来る。

export const GET: RequestHandler = async (event) => {
  const stream = await getStream(event);

  return new Response(stream, {
    headers: {
      'Content-Disposition': 'attachment; filename="example.pdf"',
      'Content-Type': 'application/pdf',
    };
  });
};

後はフロントでfetchして、streamをblobに変換して良しなにすればOK。

感想

普段はNext.jsを使っていたため、SveltekitでMPAライクにPOSTが受け付けることが出来ることに感動し、何とかAPIを使わずに出来ないかと試したがダメだった。 他にも方法があるのかもしれない。悔しい。

Zodiosでファイルのダウンロード

APIからstreamを取得してblob変換してダウンロードさせようとしたとき、vanillaのfetchならすんなり出来たのだが、Zodiosを利用すると少し躓いたので備忘録。

前提

サーバー側はこんな感じでstreamを返している。

  return new Response(stream, {
    headers: {
        'Content-Disposition': 'attachment; filename="sample.pdf',
        'Content-Type': 'application/pdf',
    },
  });

ダウンロード

フロント側はZodiosでstreamを受ける。

const api = (() => {
    const client = createApiClient('/');

    client.axiosInstance.interceptors.response.use(
        (response) => {
            response.data = {
                data: response.data,
                headers: response.headers,
            };
            return response;
        },
        (error) => {
            return Promise.reject(error);
        }
    );

    return client;
})();

const blobDownload = (blob: Blob, fileName: string): void => {
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.style.display = 'none';
    a.download = fileName;
    a.href = url;
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(url);
};

const getFileName = (headers: AxiosHeaders): string | undefined => {
    const contentDispositionHeader = headers['content-disposition'];
    if (!contentDispositionHeader) {
        return;
    }

    const contentDisposition = contentDispositionHeader
        .split(';')
        .map((item: string): string => item.trim());
        const filenameParameter = contentDisposition.find((item: string): boolean =>
            item.toLowerCase().startsWith('filename='),
        );
    if (!filenameParameter) {
        return;
    }

    const fileName = filenameParameter.split('=')[1];
    return decodeURIComponent(fileName.replace(/['"]+/g, ''));
};

// ダウンロード
const res = await api.getPdf({
    params,
    queries,
    responseType: 'blob'
});
blobDownload(res.data, getFileName(res.headers) ?? 'default.pdf');

client.axiosInstance.interceptors.response.use

Zodios のレスポンスは Axios の response.data の部分のみ返してくる。 今回だと直接 blob が返ってきてしまう。
しかし後続処理で headers の内容を参照したいので、レスポンスを改変する。

注意点としては、Zodiosが生成する api の Axios Instance をいじってしまうと他にも影響があるため、この処理専用の Api Client を生成すること。

なお設定などは変わらないのでAxios Instanceを自作するの面倒だということで、client の axiosInstance に直接ごにょごにょしているが、勿論以下のように Axios Instance を渡す形でもいい。

const axiosInstance = Axios();
axiosInstance.interceptors.response.use(
    (response) => {
        response.data = {
            data: response.data,
            headers: response.headers,
        };
        return response;
    },
    (error) => {
        return Promise.reject(error);
    }
);
const client = createApiClient('/', { axiosInstance });

responseType: 'blob'

Zodiosはある程度Axiosのオプションを受け付けているので、忘れずにresponseTypeを指定する。
responseType を指定することで stream を blob に変換までしてくれる。
指定しない場合は json 扱いされ、上手く後続処理に続けることが出来なくなる。

getFileName

ここでファイル名を取得したかったので headers が必要だったのですね。 ファイル名をフロントで決めていいのであれば、axios をいじる必要もなければ、この処理も不要です。

blobDownload

ここはよくある処理なので説明割愛。

感想

今回のはまりどころは2点。

  1. Axios で Stream を受け取るには responseType の指定が必要。
  2. Zodios で Headers を受け取るにはちょっとした改良が必要。

Rspecでsessionにダミーデータを入れたい

rspecでsessionにデータを入れてテストしたいシーンがあるかと思います。
ぱっと調べた感じだと方法が古いのか上手くいかないものが多かったので、動くものを備忘録として記事化しようと思います。

環境

Service Version
Ruby 3.3.2
Ruby on Rails 7.1
rspec-rails 6.1.3

対応内容

supportファイルの有効化

もし有効化していなければ有効化してください。

spec/rails_helper.rb

- # Rails.root.glob('spec/support/**/*.rb').sort.each { |f| require f }
+ Rails.root.glob('spec/support/**/*.rb').sort.each { |f| require f }

Session用のサポートファイル作成

spec/support/session.rb

shared_context 'session double' do
  let(:session_hash) { {} }

  before do
    session_double = instance_double(ActionDispatch::Request::Session, enabled?: true, loaded?: false)

    allow(session_double).to receive(:[]) do |key|
      session_hash[key]
    end

    allow(session_double).to receive(:[]=) do |key, value|
      session_hash[key] = value
    end

    allow(session_double).to receive(:delete) do |key|
      session_hash.delete(key)
    end

    allow(session_double).to receive(:clear) do |_key|
      session_hash.clear
    end

    allow(session_double).to receive(:fetch) do |key|
      session_hash.fetch(key)
    end

    allow(session_double).to receive(:key?) do |key|
      session_hash.key?(key)
    end

    allow_any_instance_of(ActionDispatch::Request)
      .to receive(:session).and_return(session_double)
  end
end

使い方

以下のようにsession_hashに値を設定してあげれば、それがそのままsessionとなる。

let(:session_hash) { { sample: :value } }

感想

新規案件始めるたびに忘れる。 ので記事化。

参考

Set Session in RSpec with Rails 7 API

備忘録のためにここに書いていることを日本語にしただけです。