Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion docs/extension-text2speech/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# 拡張機能: 音声合成 (Text to Speech)

> **⬆️ upstream そのまま** — upstream の実装をほぼそのまま利用
> **🔧 upstream 改良** — upstream にあるが Smalruby で機能を改良・拡張している
> 改良点: 音声合成リクエストを Smalruby の**汎用** CORS 回避プロキシ (`api.smalruby.app/cors-proxy`) 経由に切り替え。Scratch の音声合成サービスは CORS を scratch.mit.edu 限定にしたため、smalruby.app からの直叩きが CORS で失敗する。

- **Smalruby ランタイム対応**: ❌(smalruby3 gem 未対応。AWS Polly API + ブラウザ音声再生)
- **デフォルト表示**: ✅(拡張機能ライブラリにデフォルトで表示される)
Expand All @@ -10,6 +11,28 @@

入力したテキストを**音声で読み上げる**拡張機能。AWS Polly を使った音声合成(複数の声・言語対応)。upstream Scratch 標準。

### Smalruby 独自: 汎用 CORS 回避プロキシ経由

Scratch の音声合成サービス (`synthesis-service.scratch.mit.edu`) は `Access-Control-Allow-Origin` を
`scratch.mit.edu` 限定に締めたため、`smalruby.app` から直接叩くと CORS でブロックされる。
そこで VM 実装 `scratch3_text2speech/index.js` では、合成 URL (`${SERVER_HOST}/synth?...`) を
Smalruby の**汎用** CORS プロキシで包む(`=== Smalruby: Start/End of synthesis CORS proxy ===`
マーカーで囲む):

```
https://api.smalruby.app/cors-proxy?url=<encodeURIComponent(合成URL)>
```

`SERVER_HOST` は upstream の値 (`https://synthesis-service.scratch.mit.edu`) のまま維持し、URL 組み立て
箇所だけをラップするので upstream との差分が最小になる(upstream マージ時の silent revert 検知は
`test/unit/extension_text2speech_proxy.js` が担保)。

汎用 `cors-proxy` はサーバ側で対象 URL を fetch し、`audio/*` をバイナリと判定して mp3 を Base64 で返却
(`isBase64Encoded: true`。API Gateway HTTP API v2 がクライアントへバイト列にデコード)、built-in CORS で
レスポンスを返す(`infra/smalruby-api` の `GET /cors-proxy`)。translate (#857) と同じ根本原因だが、
translate は専用 Lambda、音声合成は**既存の汎用プロキシを再利用**する点が異なり、専用 Lambda の追加も
インフラの再デプロイも不要(#859)。

## ユーザーストーリー

- **小学生**として、自分の作ったキャラクターに「しゃべらせたい」
Expand Down
4 changes: 3 additions & 1 deletion docs/infra/smalruby-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

| エンドポイント | Lambda | 用途 |
|---|---|---|
| `GET /cors-proxy` | `smalruby-api-cors-proxy{stageSuffix}` | 任意 URL の CORS フリーフェッチ + Google Drive URL 変換 + バイナリ Base64 化 |
| `GET /cors-proxy` | `smalruby-api-cors-proxy{stageSuffix}` | 任意 URL の CORS フリーフェッチ + Google Drive URL 変換 + バイナリ Base64 化。音声合成 (text2speech) もこの汎用プロキシ経由で `synthesis-service.scratch.mit.edu` を叩く |
| `GET /mesh-domain` | `smalruby-api-mesh-zone{stageSuffix}` | クライアント IP から Mesh ドメイン (CRC32 ハッシュ) を生成 |
| `GET /scratch-api-proxy/projects/{projectId}` | `smalruby-api-scratch-projects{stageSuffix}` | Scratch 公式 API (project info) のステータス透過プロキシ |
| `GET /scratch-api-proxy/translate` | `smalruby-api-scratch-translate{stageSuffix}` | Scratch 公式翻訳サービスのプロキシ |
Expand Down Expand Up @@ -66,6 +66,8 @@ Scratch translate サービス (`https://translate-service.scratch.mit.edu/trans

実装: `infra/smalruby-api/lambda/scratch-api-translate.ts`

> **音声合成 (text2speech) について**: 音声合成サービス (`https://synthesis-service.scratch.mit.edu/synth`) も同じ CORS 制約があるが、専用 Lambda は作らず**汎用 `GET /cors-proxy?url=<encoded 合成URL>`** を再利用する。`cors-proxy` は `audio/*` をバイナリと判定して Base64 返却 (`isBase64Encoded: true`) するため、API Gateway 側でバイト列にデコードされ、拡張の `arrayBuffer()` がそのまま音声を得られる。フロント側の実装は `packages/scratch-vm/src/extensions/scratch3_text2speech/index.js` を参照 (#859)。

## 環境変数

`.env.example` 参照。主要なもの:
Expand Down
2 changes: 2 additions & 0 deletions docs/maintenance/smalruby-markers-vm.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ scratch-vm の **upstream ファイルに埋め込んだ Smalruby マーカー**
| `src/engine/blocks.js` | XML coords guard | `blockToXML` で x/y が finite number のときだけ XML 属性を出力。Ruby → blocks 変換の x/y 未指定 (undefined) を scratch-blocks v2 に正しく伝え、`fromRuby` 再レイアウト経路を維持する |
| `src/engine/blocks.js` | orphaned-parent guard | `getTopLevelScript` で `block.parent` が this._blocks に存在しない場合に停止。Ruby → blocks 変換中の孤立 parent id で `undefined.parent` 参照クラッシュを防ぐ |
| `src/extensions/scratch3_translate/index.js` | translate CORS proxy | `serverURL` を Smalruby プロキシ (`https://api.smalruby.app/scratch-api-proxy/`) に上書き。Scratch の翻訳サービスは CORS を scratch.mit.edu 限定にしたため smalruby.app からの直叩きが失敗する。マーカー無しで上書きしていた過去の版が v13.7.2 upstream マージで静かに revert された (#857) ため、次回以降のマージで検知できるようマーカーで囲む |
| `src/extensions/scratch3_text2speech/index.js` | synthesis CORS proxy | 音声合成 URL を Smalruby の**汎用** CORS プロキシ (`https://api.smalruby.app/cors-proxy?url=<encoded 合成URL>`) で包む。Scratch の音声合成サービスは CORS を scratch.mit.edu 限定にしたため smalruby.app からの直叩きが失敗する。汎用 cors-proxy はバイナリ音声を Base64 で返却する (API Gateway がバイト列にデコード) ので専用 Lambda は不要。`SERVER_HOST` は upstream の値のまま維持し、URL 組み立て箇所だけラップするので upstream 差分が最小。translate (#857) と同じ根本原因 (#859) |

## 関連ファイル

マーカーで囲まれたコードが参照するファイル:
- `src/extension-support/smalruby-extensions.js` — extension-manager.js のマーカーから参照
- `test/unit/blocks_operators_regex.js` — scratch3_operators.js の regex support のテスト
- `test/unit/extension_translate_proxy.js` — scratch3_translate/index.js の translate CORS proxy のテスト
- `test/unit/extension_text2speech_proxy.js` — scratch3_text2speech/index.js の synthesis CORS proxy のテスト
1 change: 1 addition & 0 deletions packages/scratch-vm/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ test/unit/*
!test/unit/extension_mesh_v2.js
!test/unit/extension_smalrubot_s1.js
!test/unit/extension_translate_proxy.js
!test/unit/extension_text2speech_proxy.js
!test/unit/extension_smalruby_ruby_each.js
!test/unit/mesh_service_v2_cost.js
!test/unit/mesh_service_v2_global_vars.js
Expand Down
20 changes: 20 additions & 0 deletions packages/scratch-vm/src/extensions/scratch3_text2speech/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ const blockIconURI = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNv
*/
const SERVER_HOST = 'https://synthesis-service.scratch.mit.edu';

// === Smalruby: Start of synthesis CORS proxy ===
// Scratch's synthesis service is CORS-locked to scratch.mit.edu, so calling it
// directly from smalruby.app fails ('Access-Control-Allow-Origin' mismatch).
// Instead of a dedicated per-service Lambda, we reuse Smalruby's *generic* CORS
// proxy (infra/smalruby-api `GET /cors-proxy?url=<encoded target URL>`). It fetches
// the target server-side and Base64-encodes binary audio (API Gateway decodes it
// back to bytes for the client), returning permissive CORS headers. See where the
// synth request URL is built (search for CORS_PROXY_HOST). SERVER_HOST stays at the
// upstream value so upstream merges only touch the URL-build site (guarded by
// test/unit/extension_text2speech_proxy.js). Same root cause as translate (#857/#859).
const CORS_PROXY_HOST = 'https://api.smalruby.app/cors-proxy';
// === Smalruby: End of synthesis CORS proxy ===

/**
* How long to wait in ms before timing out requests to synthesis server.
* @type {int}
Expand Down Expand Up @@ -721,6 +734,13 @@ class Scratch3Text2SpeechBlocks {
path += `&gender=${gender}`;
path += `&text=${encodeURIComponent(words.substring(0, 128))}`;

// === Smalruby: Start of synthesis CORS proxy ===
// Wrap the synthesis URL in the generic Smalruby CORS proxy so smalruby.app
// is not blocked by the CORS-locked Scratch service. The whole synth URL
// (including its query string) becomes the encoded `url` param.
path = `${CORS_PROXY_HOST}?url=${encodeURIComponent(path)}`;
// === Smalruby: End of synthesis CORS proxy ===

// Perform HTTP request to get audio file
return fetchWithTimeout(path, {}, SERVER_TIMEOUT)
.then(res => {
Expand Down
69 changes: 69 additions & 0 deletions packages/scratch-vm/test/unit/extension_text2speech_proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
const { test } = require('tap');

const fetchModulePath = require.resolve('../../src/util/fetch-with-timeout');
const extPath = require.resolve('../../src/extensions/scratch3_text2speech');

// The Text2Speech extension is an upstream Scratch file whose synthesis service is
// CORS-locked to scratch.mit.edu. Smalruby must route requests through its own
// generic CORS proxy (api.smalruby.app/cors-proxy?url=<encoded synth URL>) so
// smalruby.app is not blocked by CORS. This test guards that the proxy wrapping is
// not silently reverted by an upstream merge (same root cause as translate; see
// issue #859 / #857).
test('text2speech extension routes fetch through the generic Smalruby CORS proxy', (t) => {
// Stub fetchWithTimeout before the extension captures it via destructuring at
// module load time, then fresh-require the extension so it picks up the stub.
const fetchModule = require(fetchModulePath);
const originalFetch = fetchModule.fetchWithTimeout;
let capturedUrl = null;
fetchModule.fetchWithTimeout = (url) => {
capturedUrl = url;
// Short-circuit before the audio-engine path by rejecting.
return Promise.reject(new Error('stubbed'));
};

delete require.cache[extPath];
const Scratch3Text2SpeechBlocks = require(extPath);
// Minimal runtime: constructor subscribes via .on, and getCurrentLanguage
// calls getTargetForStage (null stage => falls back to DEFAULT_LANGUAGE).
const runtime = {
on: () => {},
getTargetForStage: () => null,
};
const ext = new Scratch3Text2SpeechBlocks(runtime);

// Minimal target providing custom-state storage used by _getState.
const customState = {};
const target = {
getCustomState: (key) => customState[key],
setCustomState: (key, value) => {
customState[key] = value;
},
};

// speakAndWait always resolves (it swallows fetch errors in a .catch and logs
// a warning), so we assert on the captured URL after it settles.
return ext.speakAndWait({ WORDS: 'hello' }, { target }).then(() => {
// restore before assertions so a failure does not leak the stub
fetchModule.fetchWithTimeout = originalFetch;
delete require.cache[extPath];

t.ok(capturedUrl, 'fetchWithTimeout was called');
t.match(
capturedUrl,
/^https:\/\/api\.smalruby\.app\/cors-proxy\?url=/,
'request goes to the generic Smalruby CORS proxy',
);
// The synth URL (with its query) is carried as the encoded `url` param.
t.match(
capturedUrl,
/url=https%3A%2F%2Fsynthesis-service\.scratch\.mit\.edu%2Fsynth/,
'the CORS-locked synthesis URL is wrapped as the proxy `url` param',
);
t.notMatch(
capturedUrl,
/^https:\/\/synthesis-service\.scratch\.mit\.edu/,
'the browser does not call the CORS-locked Scratch service directly',
);
t.end();
});
});
Loading