diff --git a/.claude/commands/add-test.md b/.claude/commands/add-test.md new file mode 100644 index 0000000..7ecc2ad --- /dev/null +++ b/.claude/commands/add-test.md @@ -0,0 +1,16 @@ +引数で指定されたモジュール($ARGUMENTS)のテスト不足箇所を特定し、テストを追加してください。 + +## 手順 + +1. `src/$ARGUMENTS` のソースを読み、公開関数・分岐・エッジケースを列挙 +2. `tests/` 配下の既存テストを読み、カバーされていない箇所を特定 +3. `tests/helpers/audio-mocks.ts` の共通モックを活用してテストを追加 +4. 追加したテストを `npx vitest run` で実行して PASS を確認 + +## テスト方針 + +- 純粋関数 → 入出力のみ検証 +- コンポーネント → audio-mocks.ts のモックを使用 +- Race condition → タイマー + コールバックが絡む箇所には 3 パターン(CB先行, dispose前, 連続呼び出し)を検証 +- `vi.useFakeTimers()` は動的 `import()` をブロックするため stretched playback テストでは使わない +- Worker モックは `function` キーワード必須 diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md new file mode 100644 index 0000000..874e061 --- /dev/null +++ b/.claude/commands/commit.md @@ -0,0 +1,16 @@ +コミットを作成する前に、以下の品質チェックを順番に実行してください。 + +## 事前チェック + +1. `npm run typecheck` を実行し、型エラーがないことを確認 +2. `npm run lint` を実行し、lint エラーがないことを確認 + +いずれかが失敗した場合はコミットを中止し、エラー内容を報告してください。 + +## コミット作成 + +事前チェックがすべて通った場合のみ、以下の方針でコミットを作成してください。 + +- 変更を意味単位(機能追加、バグ修正、リファクタリング等)に分割し、それぞれ個別のコミットにする +- 既存のコミット履歴のスタイル(prefix、文体)を確認し、それに合わせたメッセージを書く +- 無関係な変更を1つのコミットに混ぜない diff --git a/.claude/commands/quality-check.md b/.claude/commands/quality-check.md new file mode 100644 index 0000000..128a551 --- /dev/null +++ b/.claude/commands/quality-check.md @@ -0,0 +1,11 @@ +プロジェクト全体の品質チェックを順次実行し、結果をサマリーで報告してください。 + +以下の順序で実行: + +1. `npm run typecheck` — TypeScript 型チェック +2. `npm run check` — Biome lint + format チェック +3. `npm run test:unit` — Node 単体テスト +4. `npm run test:browser` — ブラウザテスト + +各ステップの結果を PASS / FAIL で報告し、FAIL の場合はエラー詳細を含めてください。 +最後に全体サマリーを表示してください。 diff --git a/.claude/commands/review.md b/.claude/commands/review.md new file mode 100644 index 0000000..55a2cee --- /dev/null +++ b/.claude/commands/review.md @@ -0,0 +1,14 @@ +`git diff HEAD` の変更差分を以下のバグ防止チェックリストに基づいてレビューしてください。 + +## チェック項目 + +1. **状態管理**: disposed チェック漏れ、不正な状態遷移、コールバック内での状態変更 +2. **タイマー管理**: clearTimeout/clearInterval の漏れ、dispose 時のタイマーキャンセル +3. **リソースリーク**: AudioNode disconnect 漏れ、Worker terminate 漏れ、Blob URL revoke 漏れ +4. **async 競合**: dynamic import 後の disposed チェック、pending 操作の競合 +5. **Stretcher 固有**: currentChunkIndex の更新タイミング、synthesisHop/analysisHop の関係 +6. **テスト有無**: 変更箇所に対応するテストがあるか + +## 報告形式 + +各チェック項目について PASS / WARNING / FAIL で報告し、問題がある場合は該当コードと修正案を示してください。 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7775a83 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: Test +on: [push, pull_request] +concurrency: + group: test-${{ github.head_ref || github.ref }} + cancel-in-progress: true +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + - run: npm ci + - run: npm run typecheck + - run: npm run check + - run: npx playwright install --with-deps chromium + - run: npm test diff --git a/CLAUDE.md b/CLAUDE.md index 4909f25..d528084 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,19 +11,25 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ```bash npm run build # Build with tsup (ESM + CJS → dist/) npm run dev # Build in watch mode -npm test # Run tests once (vitest) +npm test # Run all tests (unit + browser) +npm run test:unit # Node unit tests only +npm run test:browser # Playwright browser tests only npm run test:watch # Run tests in watch mode npm run typecheck # Type check (tsc --noEmit) +npm run lint # Biome lint +npm run format # Biome format (auto-fix) +npm run check # Biome check (lint + format) ``` Run a single test file: ```bash npx vitest run tests/emitter.test.ts +npx vitest run --project=unit tests/stretcher/wsola.test.ts ``` ## Architecture -10 independent modules, each a separate entry point for tree-shaking: +14 modules, each a separate entry point for tree-shaking: | Module | File | Purpose | |--------|------|---------| @@ -37,11 +43,15 @@ npx vitest run tests/emitter.test.ts | scheduler | `src/scheduler.ts` | Scheduler and clock | | synth | `src/synth.ts` | Buffer synthesis (sine, noise, click) | | adapters | `src/adapters.ts` | Framework integration (`getSnapshot`, `subscribeSnapshot`, `onFrame`) | +| stretcher | `src/stretcher/index.ts` | WSOLA time-stretch engine (preservePitch playback) | +| player | `src/player.ts` | WaaPlayer class (high-level facade over `play()`) | +| playback-state | `src/playback-state.ts` | Playback state manager (shared state machine) | +| playback-position | `src/playback-position.ts` | Position calculation (pure functions) | ### Key Design Patterns - **BYO AudioContext**: Every function takes `AudioContext` as first argument. Never creates its own. -- **Function exports only**: No classes. All modules export plain functions. +- **Function exports only**: No classes (except WaaPlayer). All modules export plain functions. - **Playback state machine**: `play()` returns a `Playback` object with states `playing` → `paused` → `stopped`. Position tracking uses `AudioContext.currentTime` for hardware-clock accuracy. Timeupdate uses `setInterval` (not rAF) to work in background tabs. - **Dual build**: tsup outputs both ESM (`dist/index.js`) and CJS (`dist/index.cjs`) with subpath exports per module. @@ -51,20 +61,54 @@ Core types in `src/types.ts`: - `PlaybackState = "playing" | "paused" | "stopped"` - `Playback` — full playback control interface - `PlaybackSnapshot` — `{ state, position, duration, progress }` -- `PlayOptions` — offset, loop, playbackRate, through, destination, etc. +- `PlayOptions` — offset, loop, playbackRate, through, destination, preservePitch, etc. ## Test Setup -- Framework: Vitest with `globals: true`, environment: `node` -- Test location: `tests/**/*.test.ts` +- **Vitest 4.x**, projects 構成 (unit + browser) +- `npm run test:unit` — Node 環境の単体テスト +- `npm run test:browser` — Playwright Chromium ブラウザテスト +- `npm test` — 両方実行 +- Test location: `tests/**/*.test.ts`, `tests/browser/*.browser.test.ts` +- Shared mocks: `tests/helpers/audio-mocks.ts` - Coverage excludes: `src/index.ts`, `src/types.ts` +### テスト方針 + +- **純粋関数テスト** → `tests/.test.ts`(入出力のみ検証) +- **コンポーネントテスト** → `tests/stretcher/.test.ts`(audio-mocks.ts 使用) +- **Race condition テスト** → タイマー + コールバックが絡む箇所には必須(3 パターン: CB先行, dispose前, 連続呼び出し) +- `vi.useFakeTimers()` は動的 `import()` をブロックするため stretched playback テストでは使わない +- Worker モックは `function` キーワード必須(Vitest 4.x で arrow fn は `new` 不可) +- URL モックはコンストラクタ機能を保持する必要あり(Vitest 4.x 内部で `new URL()` を使用) + +### バグ防止チェックリスト + +コードレビュー・実装時に確認: + +- **状態管理**: disposed チェック、不正遷移ガード、コールバック内の状態変更考慮 +- **タイマー管理**: clearTimeout/clearInterval の確実な実行、dispose 時の全タイマーキャンセル +- **リソースリーク**: AudioNode disconnect, Worker terminate, Blob URL revoke +- **async 競合**: dynamic import の disposed チェック、pending 操作キュー +- **Stretcher 固有**: currentChunkIndex は onTransition でのみ更新、synthesisHop 固定・analysisHop を tempo 依存に + ## Build Configuration - Target: ES2020 - TypeScript: strict mode with `noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexedAccess` - tsup: splitting enabled, sourcemaps, no minification - Each module in `src/` is a separate entry point in `tsup.config.ts` +- Linter/Formatter: Biome (`biome.json`) + +## Adding a New Module + +1. `src/.ts` 作成(BYO AudioContext パターン) +2. 型を `src/types.ts` に追加 +3. `tsup.config.ts` にエントリ追加 +4. `package.json` の `exports` にサブパス追加 +5. `src/index.ts` から re-export +6. テスト作成(`tests/.test.ts`) +7. この CLAUDE.md の Architecture テーブルに追記 ## Demo Site diff --git a/README.md b/README.md index ec2f2dc..65f6de8 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,80 @@ # waa-play [![npm version](https://img.shields.io/npm/v/waa-play)](https://www.npmjs.com/package/waa-play) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -[![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/) -Convenient composable building blocks for Web Audio API. +Web Audio API modules with WSOLA time-stretching, chunk-based streaming, and waveform extraction -Time-stretch / Streaming / Waveform / BYO AudioContext / Zero Dependencies +waa-play は Web Audio API を用いた音声操作のためのモジュール群です。ピッチ保持タイムストレッチ、ストリーミング再生、波形抽出、AudioNode チェーン接続などの機能を提供します。 -[Documentation & Demo](https://ivgtr.github.io/waa/) +Web Audio API の `playbackRate` で再生速度を変更するとピッチも連動して変化します。waa-play は、ピッチを保持したまま再生速度を変えたいユースケースのために作成しました。 -## Install +この実装は完璧なものではなく、すべてのユースケースに対応するわけではないことに注意してください。 -```bash -npm install waa-play -``` - -## Features +## Documentation & Demo -### Pitch-preserving time-stretch -再生速度を変えてもピッチが変わりません。処理は別スレッドで実行されるため、UI をブロックしません。 +https://ivgtr.github.io/waa/ -### Streaming playback -音声を段階的に処理し、バッファリング状態をイベントで通知します。ローディング UI を簡単に実装できます。 +## ピッチ保持タイムストレッチ(WSOLA) -### BYO AudioContext -既存の AudioContext やオーディオグラフをそのまま使えます。他のライブラリとの統合も容易です。 +waa-play は WSOLA (Waveform Similarity Overlap-Add) アルゴリズムを採用しています。 +音声信号を小さなフレームに分割し、類似した波形を重ね合わせて再構築することで、ピッチを変えずに時間的な伸縮を実現します。 -### Framework integration -React・Vue・Svelte など、お好みのフレームワークで再生状態をリアクティブに扱えます。 +HTML5 Audio 要素の `preservePitch` オプションと同様の効果を目指していますが、いくつかの制約があります。 +WSOLA の処理はクライアントサイドで行われるため、CPU リソースを多く消費する可能性があります。 +また、AudioBuffer 全体を事前に読み込む必要があるため、長い音声ファイルでは変換に時間がかかる場合があります。 -### Waveform extraction -波形データを取得し、プログレスバーや波形ビジュアライザーを構築できます。 - -### Background-tab safe -バックグラウンドタブでも再生位置の追跡が継続します。 - -## Quick Start +これらの制約に対して、Worker による並列処理やチャンクベースのストリーミングで対処しています。 -### Class API (WaaPlayer) +### Worker ベースの変換 -最もシンプルな使い方です。`WaaPlayer` が `AudioContext` を内部管理し、全モジュールの機能を統合して提供します。 +WSOLA 変換は Web Worker 内で実行され、メインスレッドのパフォーマンスへの影響を最小限に抑えています。 +Worker は複数生成され、変換タスクがキューイングされて効率的に処理されます。 -```ts -import { WaaPlayer } from "waa-play"; +### チャンクベースのストリーミング再生 -const player = new WaaPlayer(); -const buffer = await player.load("/audio/track.mp3"); +変換済みの音声がチャンク単位で順次再生されます。 +これにより、長時間の音声でもすべての変換を待つことなく、スムーズな再生が可能です。 -const gain = player.createGain(0.8); -const playback = player.play(buffer, { through: [gain] }); +### イベント通知 -player.dispose(); -``` +再生位置の更新や再生終了などのイベントが通知され、UI の更新や他の処理に利用できます。 -既存の `AudioContext` を渡すこともできます。 +## Quick Start -```ts -const player = new WaaPlayer(existingAudioContext); +```bash +npm install waa-play ``` -### Function API +### 最も簡単な使い方(WaaPlayer) -個別の関数だけ import したい場合はこちら。全関数が `AudioContext` を第一引数に取ります。 +`WaaPlayer` が `AudioContext` を内部管理し、全モジュールの機能を統合して提供します。 ```ts -import { createContext, loadBuffer, play, createGain } from "waa-play"; +import { WaaPlayer } from "waa-play"; + +const player = new WaaPlayer(); +const buffer = await player.load("/audio/track.mp3"); -const ctx = createContext(); -const buffer = await loadBuffer(ctx, "/audio/track.mp3"); +const gain = player.createGain(0.8); +const playback = player.play(buffer, { through: [gain] }); // 再生 -const gain = createGain(ctx, 0.8); -const playback = play(ctx, buffer, { through: [gain] }); +playback.setPlaybackRate(1.5); // 再生速度を 1.5 倍に変更(リアルタイム反映) playback.on("timeupdate", ({ position, duration }) => { - console.log(`${position.toFixed(1)}s / ${duration.toFixed(1)}s`); + console.log(`${position.toFixed(2)}s / ${duration.toFixed(2)}s`); }); -``` -## Modules +playback.dispose(); // 再生停止・リソース解放 +``` -必要なものだけ個別に import できます。 +既存の `AudioContext` を渡すこともできます。これは例えば、他の AudioNode と接続したい場合に有用です。 ```ts -import { play } from "waa-play/play"; -import { loadBuffer } from "waa-play/buffer"; -import { WaaPlayer } from "waa-play/player"; +const player = new WaaPlayer(existingAudioContext); ``` -| Module | 概要 | -|--------|------| -| `player` | 全モジュールを統合するクラスベース API | -| `context` | AudioContext のライフサイクル管理 | -| `buffer` | 音声ファイルのロード・デコード | -| `play` | 再生制御(play / pause / seek / loop / preservePitch) | -| `emitter` | 型安全なイベントエミッター | -| `nodes` | AudioNode のファクトリとチェーン接続 | -| `waveform` | 波形データの抽出(ピーク・RMS) | -| `fade` | フェード処理(in / out / crossfade) | -| `scheduler` | スケジューリングと BPM ベースのクロック | -| `synth` | 波形バッファの生成(sin / noise / click) | -| `adapters` | フレームワーク統合(React / Vue / Svelte) | -| `stretcher` | ピッチ保持タイムストレッチ(WSOLA) | - -## API - -詳細な使い方・API リファレンスは [Documentation & Demo](https://ivgtr.github.io/waa/) をご覧ください。 - -### WaaPlayer - -| メソッド | 概要 | -|----------|------| -| `resume()` / `ensureRunning()` / `now()` | AudioContext 制御 | -| `load(url)` / `loadFromBlob(blob)` / `loadAll(map)` | 音声ロード | -| `play(buffer, options?)` | 再生(`Playback` を返す) | -| `createGain()` / `createFilter()` / `createPanner()` / `createCompressor()` / `createAnalyser()` | ノード生成 | -| `chain(...nodes)` / `disconnectChain(...nodes)` | グラフ接続 | -| `fadeIn()` / `fadeOut()` / `crossfade()` / `autoFade()` | フェード | -| `extractPeaks()` / `extractPeakPairs()` / `extractRMS()` | 波形抽出 | -| `createScheduler()` / `createClock()` | スケジューリング | -| `createSineBuffer()` / `createNoiseBuffer()` / `createClickBuffer()` | バッファ合成 | -| `getSnapshot()` / `subscribeSnapshot()` / `onFrame()` / `whenEnded()` / `whenPosition()` | アダプター | -| `dispose()` | リソース解放 | - ## License -MIT +MIT © [ivgtr](https://github.com/ivgtr) + +[![Twitter Follow](https://img.shields.io/twitter/follow/ivgtr?style=social)](https://twitter.com/ivgtr) [![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE) diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..bb308ed --- /dev/null +++ b/biome.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!!**/dist", "!!**/demo"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedVariables": "warn", + "noUnusedImports": "warn" + }, + "style": { + "noNonNullAssertion": "off", + "useConst": "error" + }, + "suspicious": { + "noExplicitAny": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/demo/astro.config.mjs b/demo/astro.config.mjs index 75c84df..eada815 100644 --- a/demo/astro.config.mjs +++ b/demo/astro.config.mjs @@ -42,24 +42,14 @@ export default defineConfig({ ], }, { - label: 'ユースケース', - translations: { en: 'Use Cases', 'zh-CN': '使用场景' }, + label: 'ユースケースと例', + translations: { en: 'Use Cases & Demos', 'zh-CN': '使用场景与演示' }, items: [ - { label: 'エフェクトとフェード', translations: { en: 'Effects & Fade', 'zh-CN': '效果与淡化' }, slug: 'use-cases/effects-and-fade' }, - { label: 'ビジュアライゼーション', translations: { en: 'Visualization', 'zh-CN': '可视化' }, slug: 'use-cases/visualization' }, - { label: 'テンポ変更とリズム', translations: { en: 'Tempo & Rhythm', 'zh-CN': '节奏与韵律' }, slug: 'use-cases/tempo-and-rhythm' }, - { label: 'フレームワーク統合', translations: { en: 'Framework Integration', 'zh-CN': '框架集成' }, slug: 'use-cases/framework-integration' }, - ], - }, - { - label: 'デモ', - translations: { en: 'Demos', 'zh-CN': '演示' }, - items: [ - { label: '基本再生', translations: { en: 'Basic Playback', 'zh-CN': '基本播放' }, slug: 'demos/basic-playback' }, - { label: 'エフェクトとフェード', translations: { en: 'Effects & Fade', 'zh-CN': '效果与淡化' }, slug: 'demos/effects-and-fade' }, - { label: 'ビジュアライゼーション', translations: { en: 'Visualization', 'zh-CN': '可视化' }, slug: 'demos/visualization' }, - { label: 'テンポ変更', translations: { en: 'Tempo Change', 'zh-CN': '节奏变更' }, slug: 'demos/tempo-change' }, - { label: 'React 統合', translations: { en: 'React Integration', 'zh-CN': 'React 集成' }, slug: 'demos/react-integration' }, + { label: '基本再生', translations: { en: 'Basic Playback', 'zh-CN': '基本播放' }, slug: 'examples/basic-playback' }, + { label: 'エフェクトとフェード', translations: { en: 'Effects & Fade', 'zh-CN': '效果与淡化' }, slug: 'examples/effects-and-fade' }, + { label: 'ビジュアライゼーション', translations: { en: 'Visualization', 'zh-CN': '可视化' }, slug: 'examples/visualization' }, + { label: 'テンポ変更', translations: { en: 'Tempo Change', 'zh-CN': '节奏变更' }, slug: 'examples/tempo-change' }, + { label: 'フレームワーク統合', translations: { en: 'Framework Integration', 'zh-CN': '框架集成' }, slug: 'examples/framework-integration' }, ], }, ], diff --git a/demo/src/components/demo/ChunkBufferingDemo.astro b/demo/src/components/demo/ChunkBufferingDemo.astro new file mode 100644 index 0000000..3a1f069 --- /dev/null +++ b/demo/src/components/demo/ChunkBufferingDemo.astro @@ -0,0 +1,293 @@ +--- +import { t } from '../../i18n/translations'; +import type { Locale } from '../../i18n/translations'; +import SoundSourceCard from './SoundSourceCard.astro'; +import TransportButtons from './TransportButtons.astro'; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +--- + +
+ + + + + + + +
+ + + + diff --git a/demo/src/content/docs/demos/effects-and-fade.mdx b/demo/src/content/docs/demos/effects-and-fade.mdx deleted file mode 100644 index 05e606c..0000000 --- a/demo/src/content/docs/demos/effects-and-fade.mdx +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: エフェクトとフェード -description: Gain・Filter・fade in/out の interactive デモ ---- - -import EffectsDemo from '../../../components/demo/EffectsDemo.astro'; - -Audio effect chain とフェード機能のデモです。 - -- **ゲイン**: 音量をリアルタイムに調整 -- **フィルター**: Lowpass / Highpass / Bandpass の切り替え、周波数・Q値の調整 -- **フェード**: フェードイン/アウトの実行 - - - -## コード例 - -### Effect Chain - -```ts -import { WaaPlayer } from "waa-play"; - -const player = new WaaPlayer(); -const buffer = player.createSineBuffer(440, 3); - -const gain = player.createGain(0.8); - -// BiquadFilter は AudioContext から直接作成 -const filter = player.ctx.createBiquadFilter(); -filter.type = "lowpass"; -filter.frequency.value = 1000; -filter.Q.value = 5; - -player.chain(gain, filter, player.ctx.destination); - -const playback = player.play(buffer, { - through: [gain], - loop: true, -}); - -// リアルタイムにパラメータを変更 -filter.frequency.value = 500; -gain.gain.value = 0.5; -``` - -### フェード - -```ts -// フェードイン(0 → 現在のゲイン値) -player.fadeIn(gain, 0.5); // 0.5秒 - -// フェードアウト(現在のゲイン値 → 0) -player.fadeOut(gain, 1.0); // 1.0秒 -``` diff --git a/demo/src/content/docs/demos/react-integration.mdx b/demo/src/content/docs/demos/react-integration.mdx deleted file mode 100644 index 8dca033..0000000 --- a/demo/src/content/docs/demos/react-integration.mdx +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: React 統合 -description: React で waa-play を使用するインタラクティブデモ ---- - -import ReactPlayerDemo from '../../../components/demo/ReactPlayerDemo.tsx'; -import { Code } from '@astrojs/starlight/components'; -import { reactPlayerDemo } from '../../../snippets/demos/react-integration'; - -React で waa-play を使用する実動デモです。`useSyncExternalStore` と `subscribeSnapshot` / `getSnapshot` を使って、再生状態をリアクティブに管理します。 - - - -## ソースコード - -このデモのコアロジックです。UI の描画コードは省略しています。 - - - -## ポイント - -- **`usePlaybackSnapshot`**: `useSyncExternalStore` + `subscribeSnapshot` / `getSnapshot` をラップした汎用 hook。Playback の状態を React ステートとして購読 -- **`useReactPlayer`**: プレーヤーのライフサイクル(WaaPlayer 管理、バッファ生成/読み込み、再生制御)を単一の hook に集約し、コンポーネントをビューのみに保つ -- **BYO AudioContext**: `WaaPlayer` が内部で AudioContext を管理。`ensureRunning()` でユーザージェスチャ後に resume diff --git a/demo/src/content/docs/demos/tempo-change.mdx b/demo/src/content/docs/demos/tempo-change.mdx deleted file mode 100644 index 46ff570..0000000 --- a/demo/src/content/docs/demos/tempo-change.mdx +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: テンポ変更 -description: Pitch 保持 time-stretch と Stretcher Engine のデモ ---- - -import TempoDemo from '../../../components/demo/TempoDemo.astro'; - -WSOLA ベースの pitch 保持 time-stretch 機能のデモです。 - -- **テンポスライダー**: 0.5x〜2.0x でリアルタイムに再生速度を変更 -- **Pitch 保持**: チェックで WSOLA stretcher が有効化(ピッチを変えずに速度を変更) -- **チャンクストリップ**: 各チャンクの処理状態をリアルタイムに可視化 -- **Buffer health**: ストリーミングバッファの健全性を表示 - - - -## コード例 - -### Pitch 保持再生 - -```ts -import { WaaPlayer } from "waa-play"; - -const player = new WaaPlayer(); -const buffer = await player.load("/music.mp3"); - -// preservePitch: true で WSOLA stretcher が自動的に有効化 -const playback = player.play(buffer, { - playbackRate: 1.5, // 1.5倍速 - preservePitch: true, // ピッチを保持 -}); - -// リアルタイムにテンポを変更 -playback.setPlaybackRate(0.75); // 0.75倍速に変更 -``` - -### Stretcher 状態の監視 - -```ts -import { onFrame } from "waa-play"; - -const stop = player.onFrame(playback, (snapshot) => { - const { stretcher } = snapshot; - if (stretcher) { - console.log(`Buffer health: ${stretcher.bufferHealth}`); - console.log(`Ahead: ${stretcher.aheadSeconds.toFixed(1)}s`); - console.log(`Chunks: ${stretcher.chunkStates.join(", ")}`); - } -}); -``` diff --git a/demo/src/content/docs/demos/visualization.mdx b/demo/src/content/docs/demos/visualization.mdx deleted file mode 100644 index a486a52..0000000 --- a/demo/src/content/docs/demos/visualization.mdx +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: Visualization -description: 波形表示とリアルタイム frequency visualizer のデモ ---- - -import VisualizationDemo from '../../../components/demo/VisualizationDemo.astro'; - -波形データの抽出とリアルタイム周波数分析のデモです。 - -- **波形表示**: `extractPeakPairs()` でピーク/RMS データを抽出し、Canvas に描画 -- **解像度調整**: スライダーで波形の解像度をリアルタイムに変更 -- **Frequency Visualizer**: AnalyserNode を使ったリアルタイム周波数分析 - - - -## コード例 - -### 波形データの抽出 - -```ts -import { WaaPlayer } from "waa-play"; - -const player = new WaaPlayer(); -const buffer = player.createSineBuffer(440, 2); - -// ピークペアを抽出(resolution = バーの数) -const peaks = player.extractPeakPairs(buffer, { resolution: 300 }); - -// Canvas に描画 -const canvas = document.querySelector("canvas")!; -const ctx = canvas.getContext("2d")!; -const w = canvas.width, h = canvas.height, mid = h / 2; -const barWidth = w / peaks.length; - -for (let i = 0; i < peaks.length; i++) { - const { min, max } = peaks[i]; - ctx.fillStyle = "rgba(99, 102, 241, 0.6)"; - ctx.fillRect(i * barWidth, mid - max * mid, barWidth - 0.5, (max - min) * mid); -} -``` - -### リアルタイム周波数分析 - -```ts -const analyser = player.createAnalyser({ fftSize: 256 }); -player.chain(gain, analyser, player.ctx.destination); - -// フレームループで周波数データを取得 -const stop = player.onFrame(playback, () => { - const data = player.getFrequencyDataByte(analyser); - // data は Uint8Array — 各要素が 0-255 の周波数ビンの値 - drawBars(data); -}); -``` diff --git a/demo/src/content/docs/en/demos/effects-and-fade.mdx b/demo/src/content/docs/en/demos/effects-and-fade.mdx deleted file mode 100644 index 8f52bf8..0000000 --- a/demo/src/content/docs/en/demos/effects-and-fade.mdx +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: Effects & Fade -description: Interactive demo of gain, filter, and fade in/out ---- - -import EffectsDemo from '../../../../components/demo/EffectsDemo.astro'; - -A demo of audio effect chains and fade functionality. - -- **Gain**: Adjust volume in real-time -- **Filter**: Switch between Lowpass / Highpass / Bandpass, adjust frequency and Q -- **Fade**: Execute fade in/out - - - -## Code Examples - -### Effect Chain - -```ts -import { WaaPlayer } from "waa-play"; - -const player = new WaaPlayer(); -const buffer = player.createSineBuffer(440, 3); - -const gain = player.createGain(0.8); - -const filter = player.ctx.createBiquadFilter(); -filter.type = "lowpass"; -filter.frequency.value = 1000; -filter.Q.value = 5; - -player.chain(gain, filter, player.ctx.destination); - -const playback = player.play(buffer, { - through: [gain], - loop: true, -}); - -// Modify parameters in real-time -filter.frequency.value = 500; -gain.gain.value = 0.5; -``` - -### Fade - -```ts -// Fade in (0 → current gain value) -player.fadeIn(gain, 0.5); // 0.5 seconds - -// Fade out (current gain value → 0) -player.fadeOut(gain, 1.0); // 1.0 seconds -``` diff --git a/demo/src/content/docs/en/demos/react-integration.mdx b/demo/src/content/docs/en/demos/react-integration.mdx deleted file mode 100644 index f8883e2..0000000 --- a/demo/src/content/docs/en/demos/react-integration.mdx +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: React Integration -description: Interactive demo of using waa-play with React ---- - -import ReactPlayerDemo from '../../../../components/demo/ReactPlayerDemo.tsx'; -import { Code } from '@astrojs/starlight/components'; -import { reactPlayerDemo } from '../../../../snippets/demos/react-integration'; - -A working demo of waa-play with React. Uses `useSyncExternalStore` with `subscribeSnapshot` / `getSnapshot` to reactively manage playback state. - - - -## Source Code - -The core logic of this demo. UI rendering code is omitted. - - - -## Key Points - -- **`usePlaybackSnapshot`**: A reusable hook wrapping `useSyncExternalStore` + `subscribeSnapshot` / `getSnapshot` to subscribe to Playback state as React state -- **`useReactPlayer`**: Consolidates player lifecycle (WaaPlayer management, buffer generation/loading, playback controls) into a single hook, keeping the component view-only -- **BYO AudioContext**: `WaaPlayer` manages its own AudioContext internally. Call `ensureRunning()` after a user gesture to resume diff --git a/demo/src/content/docs/en/demos/tempo-change.mdx b/demo/src/content/docs/en/demos/tempo-change.mdx deleted file mode 100644 index 41f47b5..0000000 --- a/demo/src/content/docs/en/demos/tempo-change.mdx +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Tempo Change -description: Pitch-preserving time-stretch and stretcher engine demo ---- - -import TempoDemo from '../../../../components/demo/TempoDemo.astro'; - -A demo of WSOLA-based pitch-preserving time-stretch. - -- **Tempo slider**: Change playback speed from 0.5x to 2.0x in real-time -- **Preserve pitch**: Enable WSOLA stretcher (change speed without changing pitch) -- **Chunk strip**: Visualize processing state of each chunk in real-time -- **Buffer health**: Display streaming buffer health status - - - -## Code Examples - -### Pitch-preserving Playback - -```ts -import { WaaPlayer } from "waa-play"; - -const player = new WaaPlayer(); -const buffer = await player.load("/music.mp3"); - -// preservePitch: true automatically enables the WSOLA stretcher -const playback = player.play(buffer, { - playbackRate: 1.5, - preservePitch: true, -}); - -// Change tempo in real-time -playback.setPlaybackRate(0.75); -``` - -### Monitoring Stretcher State - -```ts -import { onFrame } from "waa-play"; - -const stop = player.onFrame(playback, (snapshot) => { - const { stretcher } = snapshot; - if (stretcher) { - console.log(`Buffer health: ${stretcher.bufferHealth}`); - console.log(`Ahead: ${stretcher.aheadSeconds.toFixed(1)}s`); - console.log(`Chunks: ${stretcher.chunkStates.join(", ")}`); - } -}); -``` diff --git a/demo/src/content/docs/en/demos/visualization.mdx b/demo/src/content/docs/en/demos/visualization.mdx deleted file mode 100644 index 60bf5c8..0000000 --- a/demo/src/content/docs/en/demos/visualization.mdx +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: Visualization -description: Waveform display and real-time frequency visualizer demo ---- - -import VisualizationDemo from '../../../../components/demo/VisualizationDemo.astro'; - -A demo of waveform data extraction and real-time frequency analysis. - -- **Waveform**: Extract peak/RMS data with `extractPeakPairs()` and render on Canvas -- **Resolution**: Adjust waveform resolution in real-time with a slider -- **Frequency Visualizer**: Real-time frequency analysis using AnalyserNode - - - -## Code Examples - -### Extracting Waveform Data - -```ts -import { WaaPlayer } from "waa-play"; - -const player = new WaaPlayer(); -const buffer = player.createSineBuffer(440, 2); - -const peaks = player.extractPeakPairs(buffer, { resolution: 300 }); - -const canvas = document.querySelector("canvas")!; -const ctx = canvas.getContext("2d")!; -const w = canvas.width, h = canvas.height, mid = h / 2; -const barWidth = w / peaks.length; - -for (let i = 0; i < peaks.length; i++) { - const { min, max } = peaks[i]; - ctx.fillStyle = "rgba(99, 102, 241, 0.6)"; - ctx.fillRect(i * barWidth, mid - max * mid, barWidth - 0.5, (max - min) * mid); -} -``` - -### Real-time Frequency Analysis - -```ts -const analyser = player.createAnalyser({ fftSize: 256 }); -player.chain(gain, analyser, player.ctx.destination); - -const stop = player.onFrame(playback, () => { - const data = player.getFrequencyDataByte(analyser); - // data is Uint8Array — each element is a 0-255 value for a frequency bin - drawBars(data); -}); -``` diff --git a/demo/src/content/docs/en/demos/basic-playback.mdx b/demo/src/content/docs/en/examples/basic-playback.mdx similarity index 100% rename from demo/src/content/docs/en/demos/basic-playback.mdx rename to demo/src/content/docs/en/examples/basic-playback.mdx diff --git a/demo/src/content/docs/en/use-cases/effects-and-fade.mdx b/demo/src/content/docs/en/examples/effects-and-fade.mdx similarity index 89% rename from demo/src/content/docs/en/use-cases/effects-and-fade.mdx rename to demo/src/content/docs/en/examples/effects-and-fade.mdx index b2c4d5a..7b68f0b 100644 --- a/demo/src/content/docs/en/use-cases/effects-and-fade.mdx +++ b/demo/src/content/docs/en/examples/effects-and-fade.mdx @@ -1,20 +1,27 @@ --- -title: Effects and Fade +title: Effects & Fade description: How to implement audio effects and fade transitions with waa-play --- import { Code } from '@astrojs/starlight/components'; -import { volumeControlPlayer, volumeControlFn, stereoPanPlayer, stereoPanFn, effectChainPlayer, effectChainFn, fadeInOutPlayer, autoFadePlayer, fadeInOutFn, crossfadePlayer, crossfadeFn } from '../../../../snippets/use-cases/effects-and-fade'; +import EffectsDemo from '../../../../components/demo/EffectsDemo.astro'; +import { volumeControlPlayer, volumeControlFn, stereoPanPlayer, stereoPanFn, effectChainPlayer, effectChainFn, fadeInOutPlayer, autoFadePlayer, fadeInOutFn, crossfadePlayer, crossfadeFn } from '../../../../snippets/examples/effects-and-fade'; This guide demonstrates how to implement audio effects and fade transitions using the `nodes`, `fade`, and `play` modules. +## Demo + + + ## Modules Used - **nodes**: Audio node factory functions (`createGain`, `createPanner`, `chain`, etc.) - **fade**: Fade in/out and crossfade utilities - **play**: Core playback engine (routes effect chains via the `through` option) -## 1. Volume Control +## Code Examples + +### 1. Volume Control Create a GainNode with `createGain()` and route it through the `play()` function's `through` option. Use `rampGain()` to smoothly change volume without click noise. @@ -29,7 +36,7 @@ When you pass a GainNode to the `through` option, `play()` automatically connect -## 2. Stereo Panning +### 2. Stereo Panning Create a StereoPannerNode with `createPanner()` to control left-right panning. Values range from `-1` (left) to `1` (right). @@ -42,7 +49,7 @@ Create a StereoPannerNode with `createPanner()` to control left-right panning. V -## 3. Effect Chain +### 3. Effect Chain Combine multiple effects for rich sound processing. Pass an array of nodes to `through` to automatically chain them together. @@ -59,7 +66,7 @@ Example using `chain()` to explicitly build a node graph: -## 4. Fade In/Out +### 4. Fade In/Out Use `fadeIn()` and `fadeOut()` for smooth fade effects. `autoFade()` automates fades at playback start and end. @@ -76,7 +83,7 @@ Use `autoFade()` to automatically set fades at playback start and end. -## 5. DJ Crossfade +### 5. DJ Crossfade Crossfade between two tracks for seamless DJ-style transitions. diff --git a/demo/src/content/docs/en/use-cases/framework-integration.mdx b/demo/src/content/docs/en/examples/framework-integration.mdx similarity index 75% rename from demo/src/content/docs/en/use-cases/framework-integration.mdx rename to demo/src/content/docs/en/examples/framework-integration.mdx index 32e5bdd..3adbc6b 100644 --- a/demo/src/content/docs/en/use-cases/framework-integration.mdx +++ b/demo/src/content/docs/en/examples/framework-integration.mdx @@ -4,13 +4,29 @@ description: Integration patterns with frameworks like React, Promise-based wait --- import { Code } from '@astrojs/starlight/components'; -import { reactHookPlayer, reactHookFn, promiseWaitPlayer, promiseWaitFn, smoothAnimationPlayer, smoothAnimationFn } from '../../../../snippets/use-cases/framework-integration'; +import ReactPlayerDemo from '../../../../components/demo/ReactPlayerDemo.tsx'; +import { reactPlayerDemo } from '../../../../snippets/examples/react-integration'; +import { reactHookPlayer, reactHookFn, promiseWaitPlayer, promiseWaitFn, smoothAnimationPlayer, smoothAnimationFn } from '../../../../snippets/examples/framework-integration'; This guide covers integration patterns with frameworks like React, Promise-based waiting, and UI animation synchronization. **Modules used**: `adapters`, `play` -## React Custom Hook +## React Demo + +A working demo of waa-play with React. Uses `useSyncExternalStore` with `subscribeSnapshot` / `getSnapshot` to reactively manage playback state. + + + +### Demo Source Code + +The core logic of this demo. UI rendering code is omitted. + + + +## Code Examples + +### 1. React Custom Hook `subscribeSnapshot` and `getSnapshot` are directly compatible with React's `useSyncExternalStore`. @@ -25,7 +41,7 @@ This guide covers integration patterns with frameworks like React, Promise-based -## Promise-Based Waiting +### 2. Promise-Based Waiting You can wait for playback completion or position arrival using Promises. @@ -40,7 +56,7 @@ You can wait for playback completion or position arrival using Promises. -## Smooth UI Animation +### 3. Smooth UI Animation `onFrame` enables smooth progress bar and seeker updates based on `requestAnimationFrame`. @@ -59,5 +75,5 @@ You can wait for playback completion or position arrival using Promises. ## Related API -- [WaaPlayer](/waa/api/player/) -- [Function API](/waa/api/functions/) +- [WaaPlayer](/waa/en/api/player/) +- [Function API](/waa/en/api/functions/) diff --git a/demo/src/content/docs/en/use-cases/tempo-and-rhythm.mdx b/demo/src/content/docs/en/examples/tempo-change.mdx similarity index 52% rename from demo/src/content/docs/en/use-cases/tempo-and-rhythm.mdx rename to demo/src/content/docs/en/examples/tempo-change.mdx index 27977e7..bcb6617 100644 --- a/demo/src/content/docs/en/use-cases/tempo-and-rhythm.mdx +++ b/demo/src/content/docs/en/examples/tempo-change.mdx @@ -1,16 +1,32 @@ --- -title: Tempo & Rhythm -description: Patterns for timing control including tempo changes and rhythm pattern construction. +title: Tempo Change +description: Patterns for tempo changes and monitoring buffering state --- import { Code } from '@astrojs/starlight/components'; -import { tempoChangePlayer, tempoChangeFn, bufferingMonitorPlayer, bufferingMonitorFn, beatSequencerPlayer, beatSequencerFn } from '../../../../snippets/use-cases/tempo-and-rhythm'; +import TempoDemo from '../../../../components/demo/TempoDemo.astro'; +import ChunkBufferingDemo from '../../../../components/demo/ChunkBufferingDemo.astro'; +import { tempoChangePlayer, tempoChangeFn, bufferingMonitorPlayer, bufferingMonitorFn } from '../../../../snippets/examples/tempo-and-rhythm'; -Patterns for timing control including tempo changes and rhythm pattern construction. +Patterns for tempo changes and monitoring buffering state. -**Modules used**: `play`, `scheduler`, `synth` +**Modules used**: `play`, `stretcher` -## 1. Pitch-Preserving Tempo Change +## Tempo Change Demo + +A demo of WSOLA-based pitch-preserving time-stretch. Use the tempo slider to change playback speed in real-time. + + + +## Chunk Buffering Demo + +A demo to observe the chunk buffering mechanism of the WSOLA stretcher engine. Monitor chunk processing states and events in real-time. + + + +## Code Examples + +### 1. Pitch-Preserving Tempo Change Since `preservePitch` defaults to `true`, pitch is preserved by default in normal `play()` calls. For regular speed changes (where pitch also changes), explicitly set `preservePitch: false`. @@ -25,7 +41,7 @@ The WSOLA algorithm allows you to change tempo without changing pitch. Setting ` -## 2. Monitoring Buffering State +### 2. Monitoring Buffering State Since WSOLA processing is asynchronous, buffering may occur when changing tempo. @@ -40,23 +56,8 @@ The `reason` in the `buffering` event is one of `"initial"` | `"seek"` | `"tempo -## 3. Beat Sequencer - -You can build beat patterns with precise timing using clocks and schedulers. - - - -Create a BPM-based clock with `createClock` and achieve sample-accurate timing control with lookahead scheduling via `createScheduler`. You can synthesize click sounds with `createClickBuffer`. - -
-Function API - - - -
- ## Related API -- [WaaPlayer](/waa/api/player/) -- [Function API](/waa/api/functions/) -- [Stretcher](/waa/api/stretcher/) +- [WaaPlayer](/waa/en/api/player/) +- [Function API](/waa/en/api/functions/) +- [Stretcher](/waa/en/api/stretcher/) diff --git a/demo/src/content/docs/en/use-cases/visualization.mdx b/demo/src/content/docs/en/examples/visualization.mdx similarity index 81% rename from demo/src/content/docs/en/use-cases/visualization.mdx rename to demo/src/content/docs/en/examples/visualization.mdx index ebbb748..f67fa17 100644 --- a/demo/src/content/docs/en/use-cases/visualization.mdx +++ b/demo/src/content/docs/en/examples/visualization.mdx @@ -4,13 +4,20 @@ description: Audio visualization patterns including waveform rendering and real- --- import { Code } from '@astrojs/starlight/components'; -import { staticWaveformPlayer, staticWaveformFn, playbackCursorPlayer, playbackCursorFn, realtimeFrequencyPlayer, realtimeFrequencyFn } from '../../../../snippets/use-cases/visualization'; +import VisualizationDemo from '../../../../components/demo/VisualizationDemo.astro'; +import { staticWaveformPlayer, staticWaveformFn, playbackCursorPlayer, playbackCursorFn, realtimeFrequencyPlayer, realtimeFrequencyFn } from '../../../../snippets/examples/visualization'; This guide covers three patterns for visualizing audio data: static waveform rendering, playback cursor tracking, and real-time frequency spectrum display. **Modules used**: `waveform`, `nodes`, `adapters`, `play` -## 1. Static Waveform Rendering +## Demo + + + +## Code Examples + +### 1. Static Waveform Rendering Extract peak pairs from an AudioBuffer and render the waveform to a Canvas. @@ -25,7 +32,7 @@ Extract peak pairs from an AudioBuffer and render the waveform to a Canvas. -## 2. Playback Cursor Tracking +### 2. Playback Cursor Tracking Move a cursor according to playback position and color-code the played portion. @@ -40,7 +47,7 @@ Move a cursor according to playback position and color-code the played portion. -## 3. Real-Time Frequency Display +### 3. Real-Time Frequency Display Use an AnalyserNode to display the frequency spectrum in real-time during playback. @@ -57,5 +64,5 @@ Use an AnalyserNode to display the frequency spectrum in real-time during playba ## Related API -- [WaaPlayer](/waa/api/player/) -- [Function API](/waa/api/functions/) +- [WaaPlayer](/waa/en/api/player/) +- [Function API](/waa/en/api/functions/) diff --git a/demo/src/content/docs/en/index.mdx b/demo/src/content/docs/en/index.mdx index 1e0af72..647a7df 100644 --- a/demo/src/content/docs/en/index.mdx +++ b/demo/src/content/docs/en/index.mdx @@ -11,7 +11,7 @@ hero: icon: right-arrow variant: primary - text: Live Demo - link: /waa/en/demos/basic-playback/ + link: /waa/en/examples/basic-playback/ variant: minimal --- diff --git a/demo/src/content/docs/demos/basic-playback.mdx b/demo/src/content/docs/examples/basic-playback.mdx similarity index 100% rename from demo/src/content/docs/demos/basic-playback.mdx rename to demo/src/content/docs/examples/basic-playback.mdx diff --git a/demo/src/content/docs/use-cases/effects-and-fade.mdx b/demo/src/content/docs/examples/effects-and-fade.mdx similarity index 91% rename from demo/src/content/docs/use-cases/effects-and-fade.mdx rename to demo/src/content/docs/examples/effects-and-fade.mdx index 897778a..92b1de6 100644 --- a/demo/src/content/docs/use-cases/effects-and-fade.mdx +++ b/demo/src/content/docs/examples/effects-and-fade.mdx @@ -4,17 +4,24 @@ description: waa-play で audio effect とフェード効果を実装する方 --- import { Code } from '@astrojs/starlight/components'; -import { volumeControlPlayer, volumeControlFn, stereoPanPlayer, stereoPanFn, effectChainPlayer, effectChainFn, fadeInOutPlayer, autoFadePlayer, fadeInOutFn, crossfadePlayer, crossfadeFn } from '../../../snippets/use-cases/effects-and-fade'; +import EffectsDemo from '../../../components/demo/EffectsDemo.astro'; +import { volumeControlPlayer, volumeControlFn, stereoPanPlayer, stereoPanFn, effectChainPlayer, effectChainFn, fadeInOutPlayer, autoFadePlayer, fadeInOutFn, crossfadePlayer, crossfadeFn } from '../../../snippets/examples/effects-and-fade'; このガイドでは、`nodes`、`fade`、`play` モジュールを使用して、audio effect とフェード効果を実装する方法を説明します。 +## デモ + + + ## 使用モジュール - **nodes**: Audio node のファクトリー関数(`createGain`、`createPanner`、`chain` など) - **fade**: フェードイン/アウト、クロスフェードユーティリティ - **play**: コア再生エンジン(`through` オプションで effect chain をルーティング) -## 1. ボリューム制御 +## コード例 + +### 1. ボリューム制御 `createGain()` で GainNode を作成し、`play()` の `through` オプションでルーティングします。`rampGain()` を使用することで、クリックノイズなく滑らかに音量を変更できます。 @@ -29,7 +36,7 @@ import { volumeControlPlayer, volumeControlFn, stereoPanPlayer, stereoPanFn, eff -## 2. ステレオパン +### 2. ステレオパン `createPanner()` で StereoPannerNode を作成し、左右のパンを制御できます。値は `-1`(左)から `1`(右)の範囲です。 @@ -42,7 +49,7 @@ import { volumeControlPlayer, volumeControlFn, stereoPanPlayer, stereoPanFn, eff -## 3. Effect Chain +### 3. Effect Chain 複数の effect を組み合わせて、リッチなサウンド処理を実現できます。`through` に配列でノードを渡すと、自動的にチェーン接続されます。 @@ -59,7 +66,7 @@ import { volumeControlPlayer, volumeControlFn, stereoPanPlayer, stereoPanFn, eff -## 4. フェードイン/アウト +### 4. フェードイン/アウト `fadeIn()` と `fadeOut()` を使用して、滑らかなフェード効果を実現できます。`autoFade()` を使えば、再生開始時と終了時のフェードを自動化できます。 @@ -76,7 +83,7 @@ import { volumeControlPlayer, volumeControlFn, stereoPanPlayer, stereoPanFn, eff -## 5. DJ クロスフェード +### 5. DJ クロスフェード 2つのトラック間でクロスフェードを行い、DJ ミックスのようなシームレスな切り替えを実現できます。 diff --git a/demo/src/content/docs/use-cases/framework-integration.mdx b/demo/src/content/docs/examples/framework-integration.mdx similarity index 76% rename from demo/src/content/docs/use-cases/framework-integration.mdx rename to demo/src/content/docs/examples/framework-integration.mdx index 5b6bd1d..c06b967 100644 --- a/demo/src/content/docs/use-cases/framework-integration.mdx +++ b/demo/src/content/docs/examples/framework-integration.mdx @@ -1,16 +1,32 @@ --- -title: Framework 統合 +title: フレームワーク統合 description: React など framework との統合パターン、Promise ベースの待機、UI アニメーション連携 --- import { Code } from '@astrojs/starlight/components'; -import { reactHookPlayer, reactHookFn, promiseWaitPlayer, promiseWaitFn, smoothAnimationPlayer, smoothAnimationFn } from '../../../snippets/use-cases/framework-integration'; +import ReactPlayerDemo from '../../../components/demo/ReactPlayerDemo.tsx'; +import { reactPlayerDemo } from '../../../snippets/examples/react-integration'; +import { reactHookPlayer, reactHookFn, promiseWaitPlayer, promiseWaitFn, smoothAnimationPlayer, smoothAnimationFn } from '../../../snippets/examples/framework-integration'; React など framework との統合パターンや、Promise ベースの待機、UI アニメーション連携について説明します。 **使用モジュール**: `adapters`, `play` -## React カスタムフック +## React デモ + +React で waa-play を使用する実動デモです。`useSyncExternalStore` と `subscribeSnapshot` / `getSnapshot` を使って、再生状態をリアクティブに管理します。 + + + +### デモのソースコード + +このデモのコアロジックです。UI の描画コードは省略しています。 + + + +## コード例 + +### 1. React カスタムフック `subscribeSnapshot` と `getSnapshot` は React の `useSyncExternalStore` と直接互換性があります。 @@ -25,7 +41,7 @@ React など framework との統合パターンや、Promise ベースの待機 -## Promise ベース待機 +### 2. Promise ベース待機 再生完了やポジション到達を Promise で待機できます。 @@ -40,7 +56,7 @@ React など framework との統合パターンや、Promise ベースの待機 -## スムーズ UI アニメーション +### 3. スムーズ UI アニメーション `onFrame` で `requestAnimationFrame` ベースのスムーズな progress bar・seeker 更新が可能です。 diff --git a/demo/src/content/docs/use-cases/tempo-and-rhythm.mdx b/demo/src/content/docs/examples/tempo-change.mdx similarity index 54% rename from demo/src/content/docs/use-cases/tempo-and-rhythm.mdx rename to demo/src/content/docs/examples/tempo-change.mdx index 6c389d0..63df1e0 100644 --- a/demo/src/content/docs/use-cases/tempo-and-rhythm.mdx +++ b/demo/src/content/docs/examples/tempo-change.mdx @@ -1,16 +1,32 @@ --- -title: テンポ変更とリズム -description: テンポの変更やリズムパターンの構築など、タイミング制御のパターンを紹介します。 +title: テンポ変更 +description: テンポの変更やバッファリング状態の監視パターンを紹介します --- import { Code } from '@astrojs/starlight/components'; -import { tempoChangePlayer, tempoChangeFn, bufferingMonitorPlayer, bufferingMonitorFn, beatSequencerPlayer, beatSequencerFn } from '../../../snippets/use-cases/tempo-and-rhythm'; +import TempoDemo from '../../../components/demo/TempoDemo.astro'; +import ChunkBufferingDemo from '../../../components/demo/ChunkBufferingDemo.astro'; +import { tempoChangePlayer, tempoChangeFn, bufferingMonitorPlayer, bufferingMonitorFn } from '../../../snippets/examples/tempo-and-rhythm'; -テンポの変更やリズムパターンの構築など、タイミング制御のパターンを紹介します。 +テンポの変更やバッファリング状態の監視パターンを紹介します。 -**使用モジュール**: `play`, `scheduler`, `synth` +**使用モジュール**: `play`, `stretcher` -## 1. Pitch 保存テンポ変更 +## テンポ変更デモ + +WSOLA ベースの pitch 保持 time-stretch 機能のデモです。テンポスライダーでリアルタイムに再生速度を変更できます。 + + + +## チャンクバッファリングデモ + +WSOLA stretcher エンジンのチャンクバッファリングの仕組みを観察するデモです。各チャンクの処理状態やイベントログをリアルタイムに確認できます。 + + + +## コード例 + +### 1. Pitch 保存テンポ変更 `preservePitch` のデフォルトは `true` なので、通常の `play()` 呼び出しでピッチが保存されます。通常速度変更(ピッチも変わる)には `preservePitch: false` を明示します。 @@ -25,7 +41,7 @@ WSOLA アルゴリズムにより、ピッチを変えずにテンポを変更 -## 2. Buffering 状態の監視 +### 2. Buffering 状態の監視 WSOLA 処理は非同期で行われるため、テンポ変更時に buffering が発生する可能性があります。 @@ -40,21 +56,6 @@ WSOLA 処理は非同期で行われるため、テンポ変更時に buffering -## 3. ビートシーケンサー - -クロックとスケジューラーを使用して、正確なタイミングでビートパターンを構築できます。 - - - -`createClock` で BPM ベースのクロックを作成し、`createScheduler` のルックアヘッドスケジューリングでサンプル精度のタイミング制御を実現します。`createClickBuffer` でクリック音を合成できます。 - -
-関数 API 版 - - - -
- ## 関連 API - [WaaPlayer](/waa/api/player/) diff --git a/demo/src/content/docs/use-cases/visualization.mdx b/demo/src/content/docs/examples/visualization.mdx similarity index 84% rename from demo/src/content/docs/use-cases/visualization.mdx rename to demo/src/content/docs/examples/visualization.mdx index 11e6fdc..5043657 100644 --- a/demo/src/content/docs/use-cases/visualization.mdx +++ b/demo/src/content/docs/examples/visualization.mdx @@ -1,16 +1,23 @@ --- -title: Visualization +title: ビジュアライゼーション description: 波形描画やリアルタイム周波数表示など、audio のビジュアル表現パターン --- import { Code } from '@astrojs/starlight/components'; -import { staticWaveformPlayer, staticWaveformFn, playbackCursorPlayer, playbackCursorFn, realtimeFrequencyPlayer, realtimeFrequencyFn } from '../../../snippets/use-cases/visualization'; +import VisualizationDemo from '../../../components/demo/VisualizationDemo.astro'; +import { staticWaveformPlayer, staticWaveformFn, playbackCursorPlayer, playbackCursorFn, realtimeFrequencyPlayer, realtimeFrequencyFn } from '../../../snippets/examples/visualization'; Audio データを視覚的に表現する3つのパターンを紹介します。静的な波形描画、再生カーソル連動、リアルタイム周波数表示を実装する方法を学びます。 **使用モジュール**: `waveform`, `nodes`, `adapters`, `play` -## 1. 静的波形描画 +## デモ + + + +## コード例 + +### 1. 静的波形描画 AudioBuffer からピークペアを抽出し、Canvas に波形を描画します。 @@ -25,7 +32,7 @@ AudioBuffer からピークペアを抽出し、Canvas に波形を描画しま -## 2. 再生カーソル連動 +### 2. 再生カーソル連動 再生位置に応じてカーソルを動かし、再生済み部分を色分けします。 @@ -40,7 +47,7 @@ AudioBuffer からピークペアを抽出し、Canvas に波形を描画しま -## 3. リアルタイム周波数表示 +### 3. リアルタイム周波数表示 AnalyserNode を使用して、再生中の周波数スペクトルをリアルタイムで表示します。 diff --git a/demo/src/content/docs/index.mdx b/demo/src/content/docs/index.mdx index 6432e06..220c421 100644 --- a/demo/src/content/docs/index.mdx +++ b/demo/src/content/docs/index.mdx @@ -11,7 +11,7 @@ hero: icon: right-arrow variant: primary - text: ライブデモ - link: /waa/demos/basic-playback/ + link: /waa/examples/basic-playback/ variant: minimal --- diff --git a/demo/src/content/docs/zh-cn/demos/effects-and-fade.mdx b/demo/src/content/docs/zh-cn/demos/effects-and-fade.mdx deleted file mode 100644 index bf1f771..0000000 --- a/demo/src/content/docs/zh-cn/demos/effects-and-fade.mdx +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: 效果与淡化 -description: 增益、滤波器和淡入/淡出的交互式演示 ---- - -import EffectsDemo from '../../../../components/demo/EffectsDemo.astro'; - -音频效果链和淡化功能的演示。 - -- **增益**: 实时调整音量 -- **滤波器**: 在低通/高通/带通之间切换,调整频率和 Q 值 -- **淡化**: 执行淡入/淡出 - - - -## 代码示例 - -### 效果链 - -```ts -import { WaaPlayer } from "waa-play"; - -const player = new WaaPlayer(); -const buffer = player.createSineBuffer(440, 3); - -const gain = player.createGain(0.8); - -const filter = player.ctx.createBiquadFilter(); -filter.type = "lowpass"; -filter.frequency.value = 1000; -filter.Q.value = 5; - -player.chain(gain, filter, player.ctx.destination); - -const playback = player.play(buffer, { - through: [gain], - loop: true, -}); - -// 实时修改参数 -filter.frequency.value = 500; -gain.gain.value = 0.5; -``` - -### 淡化 - -```ts -// 淡入(0 → 当前增益值) -player.fadeIn(gain, 0.5); // 0.5 秒 - -// 淡出(当前增益值 → 0) -player.fadeOut(gain, 1.0); // 1.0 秒 -``` diff --git a/demo/src/content/docs/zh-cn/demos/react-integration.mdx b/demo/src/content/docs/zh-cn/demos/react-integration.mdx deleted file mode 100644 index 0c30331..0000000 --- a/demo/src/content/docs/zh-cn/demos/react-integration.mdx +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: React 集成 -description: 在 React 中使用 waa-play 的交互式演示 ---- - -import ReactPlayerDemo from '../../../../components/demo/ReactPlayerDemo.tsx'; -import { Code } from '@astrojs/starlight/components'; -import { reactPlayerDemo } from '../../../../snippets/demos/react-integration'; - -在 React 中使用 waa-play 的实际运行演示。使用 `useSyncExternalStore` 和 `subscribeSnapshot` / `getSnapshot` 来响应式管理播放状态。 - - - -## 源代码 - -此演示的核心逻辑。省略了 UI 渲染代码。 - - - -## 要点 - -- **`usePlaybackSnapshot`**: 封装 `useSyncExternalStore` + `subscribeSnapshot` / `getSnapshot` 的通用 hook,将 Playback 状态作为 React state 订阅 -- **`useReactPlayer`**: 将播放器生命周期(WaaPlayer 管理、缓冲区生成/加载、播放控制)整合到单个 hook 中,使组件仅负责视图渲染 -- **BYO AudioContext**: `WaaPlayer` 内部管理 AudioContext。在用户手势后调用 `ensureRunning()` 以恢复 diff --git a/demo/src/content/docs/zh-cn/demos/tempo-change.mdx b/demo/src/content/docs/zh-cn/demos/tempo-change.mdx deleted file mode 100644 index 076cc35..0000000 --- a/demo/src/content/docs/zh-cn/demos/tempo-change.mdx +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: 节奏变更 -description: 保持音高的时间拉伸和拉伸引擎演示 ---- - -import TempoDemo from '../../../../components/demo/TempoDemo.astro'; - -基于 WSOLA 的保持音高时间拉伸功能演示。 - -- **节奏滑块**: 实时调整播放速度从 0.5x 到 2.0x -- **保持音高**: 启用 WSOLA 拉伸器(在不改变音高的情况下改变速度) -- **分块条**: 实时可视化每个分块的处理状态 -- **缓冲健康度**: 显示流式缓冲区的健康状态 - - - -## 代码示例 - -### 保持音高播放 - -```ts -import { WaaPlayer } from "waa-play"; - -const player = new WaaPlayer(); -const buffer = await player.load("/music.mp3"); - -// preservePitch: true 自动启用 WSOLA 拉伸器 -const playback = player.play(buffer, { - playbackRate: 1.5, - preservePitch: true, -}); - -// 实时改变节奏 -playback.setPlaybackRate(0.75); -``` - -### 监控拉伸器状态 - -```ts -import { onFrame } from "waa-play"; - -const stop = player.onFrame(playback, (snapshot) => { - const { stretcher } = snapshot; - if (stretcher) { - console.log(`Buffer health: ${stretcher.bufferHealth}`); - console.log(`Ahead: ${stretcher.aheadSeconds.toFixed(1)}s`); - console.log(`Chunks: ${stretcher.chunkStates.join(", ")}`); - } -}); -``` diff --git a/demo/src/content/docs/zh-cn/demos/visualization.mdx b/demo/src/content/docs/zh-cn/demos/visualization.mdx deleted file mode 100644 index 6adc1c2..0000000 --- a/demo/src/content/docs/zh-cn/demos/visualization.mdx +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: 可视化 -description: 波形显示和实时频率可视化演示 ---- - -import VisualizationDemo from '../../../../components/demo/VisualizationDemo.astro'; - -波形数据提取和实时频率分析的演示。 - -- **波形显示**: 使用 `extractPeakPairs()` 提取峰值/RMS 数据并在 Canvas 上渲染 -- **分辨率调整**: 通过滑块实时调整波形分辨率 -- **频率可视化**: 使用 AnalyserNode 进行实时频率分析 - - - -## 代码示例 - -### 提取波形数据 - -```ts -import { WaaPlayer } from "waa-play"; - -const player = new WaaPlayer(); -const buffer = player.createSineBuffer(440, 2); - -const peaks = player.extractPeakPairs(buffer, { resolution: 300 }); - -const canvas = document.querySelector("canvas")!; -const ctx = canvas.getContext("2d")!; -const w = canvas.width, h = canvas.height, mid = h / 2; -const barWidth = w / peaks.length; - -for (let i = 0; i < peaks.length; i++) { - const { min, max } = peaks[i]; - ctx.fillStyle = "rgba(99, 102, 241, 0.6)"; - ctx.fillRect(i * barWidth, mid - max * mid, barWidth - 0.5, (max - min) * mid); -} -``` - -### 实时频率分析 - -```ts -const analyser = player.createAnalyser({ fftSize: 256 }); -player.chain(gain, analyser, player.ctx.destination); - -const stop = player.onFrame(playback, () => { - const data = player.getFrequencyDataByte(analyser); - // data 是 Uint8Array — 每个元素是 0-255 的频率段值 - drawBars(data); -}); -``` diff --git a/demo/src/content/docs/zh-cn/demos/basic-playback.mdx b/demo/src/content/docs/zh-cn/examples/basic-playback.mdx similarity index 100% rename from demo/src/content/docs/zh-cn/demos/basic-playback.mdx rename to demo/src/content/docs/zh-cn/examples/basic-playback.mdx diff --git a/demo/src/content/docs/zh-cn/use-cases/effects-and-fade.mdx b/demo/src/content/docs/zh-cn/examples/effects-and-fade.mdx similarity index 90% rename from demo/src/content/docs/zh-cn/use-cases/effects-and-fade.mdx rename to demo/src/content/docs/zh-cn/examples/effects-and-fade.mdx index a092c2e..9e803c4 100644 --- a/demo/src/content/docs/zh-cn/use-cases/effects-and-fade.mdx +++ b/demo/src/content/docs/zh-cn/examples/effects-and-fade.mdx @@ -4,17 +4,24 @@ description: 如何使用 waa-play 实现音频效果和淡入淡出过渡 --- import { Code } from '@astrojs/starlight/components'; -import { volumeControlPlayer, volumeControlFn, stereoPanPlayer, stereoPanFn, effectChainPlayer, effectChainFn, fadeInOutPlayer, autoFadePlayer, fadeInOutFn, crossfadePlayer, crossfadeFn } from '../../../../snippets/use-cases/effects-and-fade'; +import EffectsDemo from '../../../../components/demo/EffectsDemo.astro'; +import { volumeControlPlayer, volumeControlFn, stereoPanPlayer, stereoPanFn, effectChainPlayer, effectChainFn, fadeInOutPlayer, autoFadePlayer, fadeInOutFn, crossfadePlayer, crossfadeFn } from '../../../../snippets/examples/effects-and-fade'; 本指南演示如何使用 `nodes`、`fade` 和 `play` 模块实现音频效果和淡入淡出过渡。 +## 演示 + + + ## 使用的模块 - **nodes**: 音频节点工厂函数(`createGain`、`createPanner`、`chain` 等) - **fade**: 淡入/淡出和交叉淡入淡出工具 - **play**: 核心播放引擎(通过 `through` 选项路由效果链) -## 1. 音量控制 +## 代码示例 + +### 1. 音量控制 使用 `createGain()` 创建 GainNode,并通过 `play()` 函数的 `through` 选项进行路由。使用 `rampGain()` 可以平滑地改变音量,不会产生爆音。 @@ -29,7 +36,7 @@ import { volumeControlPlayer, volumeControlFn, stereoPanPlayer, stereoPanFn, eff -## 2. 立体声声像 +### 2. 立体声声像 使用 `createPanner()` 创建 StereoPannerNode 以控制左右声像。值的范围从 `-1`(左)到 `1`(右)。 @@ -42,7 +49,7 @@ import { volumeControlPlayer, volumeControlFn, stereoPanPlayer, stereoPanFn, eff -## 3. 效果链 +### 3. 效果链 组合多个效果以实现丰富的声音处理。将节点数组传递给 `through` 以自动将它们链接在一起。 @@ -59,7 +66,7 @@ import { volumeControlPlayer, volumeControlFn, stereoPanPlayer, stereoPanFn, eff -## 4. 淡入/淡出 +### 4. 淡入/淡出 使用 `fadeIn()` 和 `fadeOut()` 实现平滑的淡入淡出效果。`autoFade()` 可以在播放开始和结束时自动淡入淡出。 @@ -76,7 +83,7 @@ import { volumeControlPlayer, volumeControlFn, stereoPanPlayer, stereoPanFn, eff -## 5. DJ 交叉淡入淡出 +### 5. DJ 交叉淡入淡出 在两个音轨之间交叉淡入淡出,实现无缝的 DJ 风格过渡。 diff --git a/demo/src/content/docs/zh-cn/use-cases/framework-integration.mdx b/demo/src/content/docs/zh-cn/examples/framework-integration.mdx similarity index 73% rename from demo/src/content/docs/zh-cn/use-cases/framework-integration.mdx rename to demo/src/content/docs/zh-cn/examples/framework-integration.mdx index 7fb89a5..5bf2c73 100644 --- a/demo/src/content/docs/zh-cn/use-cases/framework-integration.mdx +++ b/demo/src/content/docs/zh-cn/examples/framework-integration.mdx @@ -4,13 +4,29 @@ description: 与 React 等框架的集成模式、基于 Promise 的等待以及 --- import { Code } from '@astrojs/starlight/components'; -import { reactHookPlayer, reactHookFn, promiseWaitPlayer, promiseWaitFn, smoothAnimationPlayer, smoothAnimationFn } from '../../../../snippets/use-cases/framework-integration'; +import ReactPlayerDemo from '../../../../components/demo/ReactPlayerDemo.tsx'; +import { reactPlayerDemo } from '../../../../snippets/examples/react-integration'; +import { reactHookPlayer, reactHookFn, promiseWaitPlayer, promiseWaitFn, smoothAnimationPlayer, smoothAnimationFn } from '../../../../snippets/examples/framework-integration'; 本指南介绍与 React 等框架的集成模式、基于 Promise 的等待以及 UI 动画同步。 **使用的模块**: `adapters`, `play` -## React 自定义 Hook +## React 演示 + +在 React 中使用 waa-play 的实际运行演示。使用 `useSyncExternalStore` 和 `subscribeSnapshot` / `getSnapshot` 来响应式管理播放状态。 + + + +### 演示源代码 + +此演示的核心逻辑。省略了 UI 渲染代码。 + + + +## 代码示例 + +### 1. React 自定义 Hook `subscribeSnapshot` 和 `getSnapshot` 与 React 的 `useSyncExternalStore` 直接兼容。 @@ -25,7 +41,7 @@ import { reactHookPlayer, reactHookFn, promiseWaitPlayer, promiseWaitFn, smoothA -## 基于 Promise 的等待 +### 2. 基于 Promise 的等待 您可以使用 Promise 等待播放完成或到达指定位置。 @@ -40,7 +56,7 @@ import { reactHookPlayer, reactHookFn, promiseWaitPlayer, promiseWaitFn, smoothA -## 平滑 UI 动画 +### 3. 平滑 UI 动画 `onFrame` 基于 `requestAnimationFrame` 实现平滑的进度条和播放器更新。 @@ -59,5 +75,5 @@ import { reactHookPlayer, reactHookFn, promiseWaitPlayer, promiseWaitFn, smoothA ## 相关 API -- [WaaPlayer](/waa/api/player/) -- [函数 API](/waa/api/functions/) +- [WaaPlayer](/waa/zh-cn/api/player/) +- [函数 API](/waa/zh-cn/api/functions/) diff --git a/demo/src/content/docs/zh-cn/use-cases/tempo-and-rhythm.mdx b/demo/src/content/docs/zh-cn/examples/tempo-change.mdx similarity index 52% rename from demo/src/content/docs/zh-cn/use-cases/tempo-and-rhythm.mdx rename to demo/src/content/docs/zh-cn/examples/tempo-change.mdx index fed4403..2944fcb 100644 --- a/demo/src/content/docs/zh-cn/use-cases/tempo-and-rhythm.mdx +++ b/demo/src/content/docs/zh-cn/examples/tempo-change.mdx @@ -1,16 +1,32 @@ --- -title: 节奏与韵律 -description: 介绍节奏控制模式,包括节奏变更和节奏模式构建等时序控制。 +title: 节奏变更 +description: 介绍节奏变更和缓冲状态监控模式 --- import { Code } from '@astrojs/starlight/components'; -import { tempoChangePlayer, tempoChangeFn, bufferingMonitorPlayer, bufferingMonitorFn, beatSequencerPlayer, beatSequencerFn } from '../../../../snippets/use-cases/tempo-and-rhythm'; +import TempoDemo from '../../../../components/demo/TempoDemo.astro'; +import ChunkBufferingDemo from '../../../../components/demo/ChunkBufferingDemo.astro'; +import { tempoChangePlayer, tempoChangeFn, bufferingMonitorPlayer, bufferingMonitorFn } from '../../../../snippets/examples/tempo-and-rhythm'; -介绍节奏控制模式,包括节奏变更和节奏模式构建等时序控制。 +介绍节奏变更和缓冲状态监控模式。 -**使用模块**: `play`, `scheduler`, `synth` +**使用模块**: `play`, `stretcher` -## 1. 保持音高的节奏变更 +## 节奏变更演示 + +基于 WSOLA 的保持音高时间拉伸功能演示。使用节奏滑块实时调整播放速度。 + + + +## 分块缓冲演示 + +用于观察 WSOLA 拉伸引擎分块缓冲机制的演示。实时监控分块处理状态和事件。 + + + +## 代码示例 + +### 1. 保持音高的节奏变更 由于 `preservePitch` 默认为 `true`,因此在正常的 `play()` 调用中会自动保持音高。如需常规速度变更(音高也会改变),请明确设置 `preservePitch: false`。 @@ -25,7 +41,7 @@ WSOLA 算法允许在不改变音高的情况下改变节奏。设置 `preserveP -## 2. 监控缓冲状态 +### 2. 监控缓冲状态 由于 WSOLA 处理是异步的,因此在改变节奏时可能会发生缓冲。 @@ -40,23 +56,8 @@ WSOLA 算法允许在不改变音高的情况下改变节奏。设置 `preserveP -## 3. 节拍序列器 - -您可以使用时钟和调度器以精确的时序构建节拍模式。 - - - -使用 `createClock` 创建基于 BPM 的时钟,通过 `createScheduler` 的前瞻调度实现采样精确的时序控制。您可以使用 `createClickBuffer` 合成咔嗒声。 - -
-函数 API 版 - - - -
- ## 相关 API -- [WaaPlayer](/waa/api/player/) -- [函数 API](/waa/api/functions/) -- [Stretcher](/waa/api/stretcher/) +- [WaaPlayer](/waa/zh-cn/api/player/) +- [函数 API](/waa/zh-cn/api/functions/) +- [Stretcher](/waa/zh-cn/api/stretcher/) diff --git a/demo/src/content/docs/zh-cn/use-cases/visualization.mdx b/demo/src/content/docs/zh-cn/examples/visualization.mdx similarity index 80% rename from demo/src/content/docs/zh-cn/use-cases/visualization.mdx rename to demo/src/content/docs/zh-cn/examples/visualization.mdx index af846d0..764aecb 100644 --- a/demo/src/content/docs/zh-cn/use-cases/visualization.mdx +++ b/demo/src/content/docs/zh-cn/examples/visualization.mdx @@ -4,13 +4,20 @@ description: 音频可视化模式,包括波形渲染和实时频率显示 --- import { Code } from '@astrojs/starlight/components'; -import { staticWaveformPlayer, staticWaveformFn, playbackCursorPlayer, playbackCursorFn, realtimeFrequencyPlayer, realtimeFrequencyFn } from '../../../../snippets/use-cases/visualization'; +import VisualizationDemo from '../../../../components/demo/VisualizationDemo.astro'; +import { staticWaveformPlayer, staticWaveformFn, playbackCursorPlayer, playbackCursorFn, realtimeFrequencyPlayer, realtimeFrequencyFn } from '../../../../snippets/examples/visualization'; 本指南介绍三种音频数据可视化模式:静态波形渲染、播放光标跟踪和实时频谱显示。 **使用的模块**: `waveform`, `nodes`, `adapters`, `play` -## 1. 静态波形渲染 +## 演示 + + + +## 代码示例 + +### 1. 静态波形渲染 从 AudioBuffer 提取峰值对并在 Canvas 上渲染波形。 @@ -25,7 +32,7 @@ import { staticWaveformPlayer, staticWaveformFn, playbackCursorPlayer, playbackC -## 2. 播放光标跟踪 +### 2. 播放光标跟踪 根据播放位置移动光标,并对已播放部分进行颜色标记。 @@ -40,7 +47,7 @@ import { staticWaveformPlayer, staticWaveformFn, playbackCursorPlayer, playbackC -## 3. 实时频率显示 +### 3. 实时频率显示 使用 AnalyserNode 在播放期间实时显示频谱。 @@ -57,5 +64,5 @@ import { staticWaveformPlayer, staticWaveformFn, playbackCursorPlayer, playbackC ## 相关 API -- [WaaPlayer](/waa/api/player/) -- [函数 API](/waa/api/functions/) +- [WaaPlayer](/waa/zh-cn/api/player/) +- [函数 API](/waa/zh-cn/api/functions/) diff --git a/demo/src/content/docs/zh-cn/index.mdx b/demo/src/content/docs/zh-cn/index.mdx index 41d2ae3..62f32fc 100644 --- a/demo/src/content/docs/zh-cn/index.mdx +++ b/demo/src/content/docs/zh-cn/index.mdx @@ -11,7 +11,7 @@ hero: icon: right-arrow variant: primary - text: 在线演示 - link: /waa/zh-cn/demos/basic-playback/ + link: /waa/zh-cn/examples/basic-playback/ variant: minimal --- diff --git a/demo/src/demo/demo-controller.ts b/demo/src/demo/demo-controller.ts index fde24c9..38a0e57 100644 --- a/demo/src/demo/demo-controller.ts +++ b/demo/src/demo/demo-controller.ts @@ -151,6 +151,7 @@ export function createDemoController(options: DemoControllerOptions): DemoContro function resetCursor() { if (waveformCursor) waveformCursor.style.left = '0%'; + if (waveformCanvas && peaks.length > 0) drawWaveform(waveformCanvas, peaks, 0); if (waveformShowTime && timeCurrent) timeCurrent.textContent = formatTime(0); prevProgress = -1; prevTimeText = ''; @@ -200,7 +201,10 @@ export function createDemoController(options: DemoControllerOptions): DemoContro if (waveformEnabled && waveformCanvas) { on(window, 'resize', () => { - if (peaks.length > 0) drawWaveform(waveformCanvas, peaks); + if (peaks.length > 0) { + const p = prevProgress < 0 ? 0 : prevProgress / 100; + drawWaveform(waveformCanvas, peaks, p); + } }); if (waveformSeekable) { @@ -215,9 +219,11 @@ export function createDemoController(options: DemoControllerOptions): DemoContro if (currentPlayback && (currentPlayback.getState() === 'playing' || currentPlayback.getState() === 'paused')) { currentPlayback.seek(position); + drawWaveform(waveformCanvas, peaks, ratio); } else { pendingSeekPosition = position; if (waveformCursor) waveformCursor.style.left = `${ratio * 100}%`; + drawWaveform(waveformCanvas, peaks, ratio); if (waveformShowTime && timeCurrent) timeCurrent.textContent = formatTime(position); } }); @@ -258,10 +264,11 @@ export function createDemoController(options: DemoControllerOptions): DemoContro // Frame loop stopFrameLoop = player.onFrame(currentPlayback, (snapshot) => { - // Diff-based cursor update + // Diff-based cursor + waveform update const progressPct = snapshot.progress * 100; - if (waveformCursor && Math.abs(progressPct - prevProgress) > 0.05) { - waveformCursor.style.left = `${progressPct}%`; + if (Math.abs(progressPct - prevProgress) > 0.05) { + if (waveformCursor) waveformCursor.style.left = `${progressPct}%`; + if (waveformCanvas && peaks.length > 0) drawWaveform(waveformCanvas, peaks, snapshot.progress); prevProgress = progressPct; } // Diff-based time update @@ -320,7 +327,8 @@ export function createDemoController(options: DemoControllerOptions): DemoContro updateWaveform(resolution: number) { if (!currentBuffer || !waveformCanvas) return; peaks = player.extractPeakPairs(currentBuffer, { resolution }); - drawWaveform(waveformCanvas, peaks); + const p = prevProgress < 0 ? 0 : prevProgress / 100; + drawWaveform(waveformCanvas, peaks, p); }, }; diff --git a/demo/src/demo/demo-utils.ts b/demo/src/demo/demo-utils.ts index 98ccdce..92d189d 100644 --- a/demo/src/demo/demo-utils.ts +++ b/demo/src/demo/demo-utils.ts @@ -36,7 +36,7 @@ export function $(id: string): T { export function drawWaveform( canvas: HTMLCanvasElement, pairs: PeakPair[], - fillStyle = 'rgba(99, 102, 241, 0.6)', + progress = 0, ): void { const parent = canvas.parentElement; if (!parent) return; @@ -50,7 +50,9 @@ export function drawWaveform( c.clearRect(0, 0, w, h); for (let i = 0; i < pairs.length; i++) { const { min, max } = pairs[i]!; - c.fillStyle = fillStyle; + c.fillStyle = i / pairs.length < progress + ? 'rgba(99, 102, 241, 0.9)' + : 'rgba(99, 102, 241, 0.4)'; c.fillRect(i * barWidth, mid - max * mid, barWidth - 0.5, (max - min) * mid); } } diff --git a/demo/src/demo/style.css b/demo/src/demo/style.css index 9876116..fab2bde 100644 --- a/demo/src/demo/style.css +++ b/demo/src/demo/style.css @@ -526,6 +526,108 @@ 50% { opacity: 0.4; } } +/* ── Chunk Buffering Legend ──────────────────────────────────── */ + +.demo-wrapper .cb-legend { + margin-top: 1rem; + padding: 0.75rem; + background: var(--bg); + border-radius: var(--radius); +} + +.demo-wrapper .cb-legend h3 { + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.demo-wrapper .cb-legend-grid { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1rem; +} + +.demo-wrapper .cb-legend-item { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.75rem; + color: var(--text-muted); +} + +.demo-wrapper .cb-legend-swatch { + width: 14px; + height: 14px; + border-radius: 2px; + flex-shrink: 0; +} + +.demo-wrapper .cb-swatch-pending { background-color: #3f3f46; } +.demo-wrapper .cb-swatch-queued { background-color: #a16207; } +.demo-wrapper .cb-swatch-converting { background-color: #6366f1; } +.demo-wrapper .cb-swatch-ready { background-color: #22c55e; } +.demo-wrapper .cb-swatch-evicted { background-color: #27272a; } +.demo-wrapper .cb-swatch-playhead { background-color: #fff; outline: 2px solid #fff; outline-offset: -1px; width: 10px; height: 10px; margin: 2px; } + +/* ── Chunk Buffering Event Log ──────────────────────────────── */ + +.demo-wrapper .cb-event-log { + margin-top: 1rem; + padding: 0.75rem; + background: var(--bg); + border-radius: var(--radius); +} + +.demo-wrapper .cb-event-log h3 { + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.demo-wrapper .cb-event-log-entries { + max-height: 200px; + overflow-y: auto; +} + +.demo-wrapper .cb-log-entry { + display: flex; + gap: 0.5rem; + font-size: 0.75rem; + padding: 0.2rem 0; + border-bottom: 1px solid var(--border); + color: var(--text-muted); +} + +.demo-wrapper .cb-log-entry:last-child { + border-bottom: none; +} + +.demo-wrapper .cb-log-timestamp { + color: var(--text-muted); + opacity: 0.6; + font-variant-numeric: tabular-nums; + flex-shrink: 0; +} + +.demo-wrapper .cb-log-message { + color: var(--text); +} + +.demo-wrapper .cb-log-entry-new { + animation: cb-fade-in 0.3s ease; +} + +@keyframes cb-fade-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + /* ── Visualizer ──────────────────────────────────────────────── */ .demo-wrapper #visualizer-canvas { diff --git a/demo/src/i18n/translations.ts b/demo/src/i18n/translations.ts index 65b6807..4c71094 100644 --- a/demo/src/i18n/translations.ts +++ b/demo/src/i18n/translations.ts @@ -53,6 +53,17 @@ const translations = { 'demo.viz.resolution': '解像度', 'demo.tempo.title': 'テンポ変更', 'demo.tempo.source-desc': 'Pitch 保持の time-stretch を体験するには、長めの音源を使用してください。', + // Chunk Buffering demo + 'demo.cb.title': 'チャンクバッファリング', + 'demo.cb.source-desc': 'チャンク分割の仕組みを確認するには、長めの音源を使用してください。', + 'demo.cb.legend': 'チャンク状態', + 'demo.cb.pending': '未処理', + 'demo.cb.queued': 'キュー済み', + 'demo.cb.converting': '変換中', + 'demo.cb.ready': '再生可能', + 'demo.cb.evicted': '解放済み', + 'demo.cb.playhead': '再生位置', + 'demo.cb.event-log': 'イベントログ', // Footer 'footer.zero-deps': 'Zero dependencies', 'footer.framework-agnostic': 'Framework-agnostic', @@ -102,6 +113,16 @@ const translations = { 'demo.viz.resolution': 'Resolution', 'demo.tempo.title': 'Tempo Change', 'demo.tempo.source-desc': 'Use a longer sound source to experience pitch-preserving time-stretch.', + 'demo.cb.title': 'Chunk Buffering', + 'demo.cb.source-desc': 'Use a longer sound source to observe chunk splitting behavior.', + 'demo.cb.legend': 'Chunk States', + 'demo.cb.pending': 'Pending', + 'demo.cb.queued': 'Queued', + 'demo.cb.converting': 'Converting', + 'demo.cb.ready': 'Ready', + 'demo.cb.evicted': 'Evicted', + 'demo.cb.playhead': 'Playhead', + 'demo.cb.event-log': 'Event Log', 'footer.zero-deps': 'Zero dependencies', 'footer.framework-agnostic': 'Framework-agnostic', 'footer.sample-accurate': 'Sample-accurate', @@ -150,6 +171,16 @@ const translations = { 'demo.viz.resolution': '分辨率', 'demo.tempo.title': '节奏变更', 'demo.tempo.source-desc': '使用较长的音源来体验保持音高的时间拉伸。', + 'demo.cb.title': '分块缓冲', + 'demo.cb.source-desc': '使用较长的音源来观察分块行为。', + 'demo.cb.legend': '分块状态', + 'demo.cb.pending': '待处理', + 'demo.cb.queued': '已排队', + 'demo.cb.converting': '转换中', + 'demo.cb.ready': '就绪', + 'demo.cb.evicted': '已释放', + 'demo.cb.playhead': '播放位置', + 'demo.cb.event-log': '事件日志', 'footer.zero-deps': '零依赖', 'footer.framework-agnostic': '框架无关', 'footer.sample-accurate': '采样精度', diff --git a/demo/src/pages/demo.astro b/demo/src/pages/demo.astro index 4d4ef17..be5cd02 100644 --- a/demo/src/pages/demo.astro +++ b/demo/src/pages/demo.astro @@ -1,3 +1,3 @@ --- -return Astro.redirect('/waa/demos/basic-playback/'); +return Astro.redirect('/waa/examples/basic-playback/'); --- diff --git a/demo/src/pages/en/demo.astro b/demo/src/pages/en/demo.astro index cd80cbf..ceb7619 100644 --- a/demo/src/pages/en/demo.astro +++ b/demo/src/pages/en/demo.astro @@ -1,3 +1,3 @@ --- -return Astro.redirect('/waa/en/demos/basic-playback/'); +return Astro.redirect('/waa/en/examples/basic-playback/'); --- diff --git a/demo/src/pages/zh-cn/demo.astro b/demo/src/pages/zh-cn/demo.astro index 3b15d29..8a3a7ee 100644 --- a/demo/src/pages/zh-cn/demo.astro +++ b/demo/src/pages/zh-cn/demo.astro @@ -1,3 +1,3 @@ --- -return Astro.redirect('/waa/zh-cn/demos/basic-playback/'); +return Astro.redirect('/waa/zh-cn/examples/basic-playback/'); --- diff --git a/demo/src/snippets/use-cases/effects-and-fade.ts b/demo/src/snippets/examples/effects-and-fade.ts similarity index 100% rename from demo/src/snippets/use-cases/effects-and-fade.ts rename to demo/src/snippets/examples/effects-and-fade.ts diff --git a/demo/src/snippets/use-cases/framework-integration.ts b/demo/src/snippets/examples/framework-integration.ts similarity index 100% rename from demo/src/snippets/use-cases/framework-integration.ts rename to demo/src/snippets/examples/framework-integration.ts diff --git a/demo/src/snippets/demos/react-integration.ts b/demo/src/snippets/examples/react-integration.ts similarity index 100% rename from demo/src/snippets/demos/react-integration.ts rename to demo/src/snippets/examples/react-integration.ts diff --git a/demo/src/snippets/use-cases/tempo-and-rhythm.ts b/demo/src/snippets/examples/tempo-and-rhythm.ts similarity index 100% rename from demo/src/snippets/use-cases/tempo-and-rhythm.ts rename to demo/src/snippets/examples/tempo-and-rhythm.ts diff --git a/demo/src/snippets/use-cases/visualization.ts b/demo/src/snippets/examples/visualization.ts similarity index 100% rename from demo/src/snippets/use-cases/visualization.ts rename to demo/src/snippets/examples/visualization.ts diff --git a/package-lock.json b/package-lock.json index 0edd827..15f7dd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,176 @@ "version": "0.2.0", "license": "MIT", "devDependencies": { + "@biomejs/biome": "^2.3.14", + "@vitest/browser": "^4.0.18", + "@vitest/browser-playwright": "^4.0.18", + "playwright": "^1.58.2", "tsup": "^8.4.0", "typescript": "^5.7.0", - "vitest": "^2.1.0" + "vitest": "^4.0.18" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.14.tgz", + "integrity": "sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.14", + "@biomejs/cli-darwin-x64": "2.3.14", + "@biomejs/cli-linux-arm64": "2.3.14", + "@biomejs/cli-linux-arm64-musl": "2.3.14", + "@biomejs/cli-linux-x64": "2.3.14", + "@biomejs/cli-linux-x64-musl": "2.3.14", + "@biomejs/cli-win32-arm64": "2.3.14", + "@biomejs/cli-win32-x64": "2.3.14" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.14.tgz", + "integrity": "sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.14.tgz", + "integrity": "sha512-PNkLNQG6RLo8lG7QoWe/hhnMxJIt1tEimoXpGQjwS/dkdNiKBLPv4RpeQl8o3s1OKI3ZOR5XPiYtmbGGHAOnLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.14.tgz", + "integrity": "sha512-KT67FKfzIw6DNnUNdYlBg+eU24Go3n75GWK6NwU4+yJmDYFe9i/MjiI+U/iEzKvo0g7G7MZqoyrhIYuND2w8QQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.14.tgz", + "integrity": "sha512-LInRbXhYujtL3sH2TMCH/UBwJZsoGwfQjBrMfl84CD4hL/41C/EU5mldqf1yoFpsI0iPWuU83U+nB2TUUypWeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.14.tgz", + "integrity": "sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.14.tgz", + "integrity": "sha512-KQU7EkbBBuHPW3/rAcoiVmhlPtDSGOGRPv9js7qJVpYTzjQmVR+C9Rfcz+ti8YCH+zT1J52tuBybtP4IodjxZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.14.tgz", + "integrity": "sha512-+IKYkj/pUBbnRf1G1+RlyA3LWiDgra1xpS7H2g4BuOzzRbRB+hmlw0yFsLprHhbbt7jUzbzAbAjK/Pn0FDnh1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.14.tgz", + "integrity": "sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" } }, "node_modules/@esbuild/aix-ppc64": { @@ -495,6 +662,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -845,6 +1019,31 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -852,39 +1051,88 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/browser": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.18.tgz", + "integrity": "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/mocker": "4.0.18", + "@vitest/utils": "4.0.18", + "magic-string": "^0.30.21", + "pixelmatch": "7.1.0", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.0.3", + "ws": "^8.18.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.18" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.18.tgz", + "integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.0.18", + "@vitest/mocker": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", + "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0" + "vite": "^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -896,84 +1144,66 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/runner/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1036,32 +1266,15 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1123,16 +1336,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1287,13 +1490,6 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1317,6 +1513,16 @@ "ufo": "^1.6.1" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1365,6 +1571,17 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1372,16 +1589,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1412,6 +1619,19 @@ "node": ">= 6" } }, + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -1424,6 +1644,63 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -1572,6 +1849,21 @@ "dev": true, "license": "ISC" }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -1683,34 +1975,24 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": ">=6" } }, "node_modules/tree-kill": { @@ -1805,21 +2087,24 @@ "license": "MIT" }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -1828,19 +2113,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -1861,511 +2152,60 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-node/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, @@ -2373,10 +2213,19 @@ "@edge-runtime/vm": { "optional": true }, + "@opentelemetry/api": { + "optional": true + }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { "optional": true }, "@vitest/ui": { @@ -2390,12 +2239,15 @@ } } }, - "node_modules/vitest/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/why-is-node-running": { "version": "2.3.0", @@ -2413,6 +2265,28 @@ "engines": { "node": ">=8" } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index e7550b5..6f99f69 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "waa-play", "version": "0.2.0", - "description": "Convenient composable building blocks for Web Audio API. Time-stretch, streaming playback, waveform extraction, BYO AudioContext, zero dependencies.", + "description": "Web Audio API modules with WSOLA time-stretching, chunk-based streaming, and waveform extraction", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -98,9 +98,13 @@ "build": "tsup", "dev": "tsup --watch", "test": "vitest run", + "test:unit": "vitest run --project=unit", + "test:browser": "vitest run --project=browser", "test:watch": "vitest", "typecheck": "tsc --noEmit", - "lint": "tsc --noEmit", + "lint": "biome lint .", + "format": "biome format --write .", + "check": "biome check .", "prepublishOnly": "npm run build" }, "keywords": [ @@ -123,10 +127,13 @@ }, "homepage": "https://ivgtr.github.io/waa/", "bugs": "https://github.com/ivgtr/waa/issues", - "dependencies": {}, "devDependencies": { + "@biomejs/biome": "^2.3.14", + "@vitest/browser": "^4.0.18", + "@vitest/browser-playwright": "^4.0.18", + "playwright": "^1.58.2", "tsup": "^8.4.0", "typescript": "^5.7.0", - "vitest": "^2.1.0" + "vitest": "^4.0.18" } } diff --git a/src/adapters.ts b/src/adapters.ts index fcd2961..65af6c0 100644 --- a/src/adapters.ts +++ b/src/adapters.ts @@ -13,7 +13,7 @@ function computeSnapshot(playback: Playback): PlaybackSnapshot { const duration = playback.getDuration(); const progress = playback.getProgress(); - const getter = (playback as unknown as Record)["_getStretcherSnapshot"]; + const getter = (playback as unknown as Record)._getStretcherSnapshot; let stretcher: PlaybackSnapshot["stretcher"]; if (typeof getter === "function") { stretcher = (getter as () => PlaybackSnapshot["stretcher"])(); @@ -66,10 +66,7 @@ export function getSnapshot(playback: Playback): PlaybackSnapshot { * const snapshot = useSyncExternalStore(subscribe, snap, snap); * ``` */ -export function subscribeSnapshot( - playback: Playback, - callback: () => void, -): () => void { +export function subscribeSnapshot(playback: Playback, callback: () => void): () => void { const count = (subscriberCount.get(playback) ?? 0) + 1; subscriberCount.set(playback, count); @@ -148,10 +145,10 @@ export function whenEnded(playback: Playback): Promise { * Return a `Promise` that resolves when the playback position reaches or * exceeds `position` seconds. */ -export function whenPosition( - playback: Playback, - position: number, -): Promise { +export function whenPosition(playback: Playback, position: number): Promise { + if (playback.getCurrentTime() >= position) { + return Promise.resolve(); + } return new Promise((resolve) => { const unsub = playback.on("timeupdate", ({ position: current }) => { if (current >= position) { diff --git a/src/buffer.ts b/src/buffer.ts index 71a3b89..42e4d4c 100644 --- a/src/buffer.ts +++ b/src/buffer.ts @@ -54,10 +54,7 @@ export async function loadBuffer( /** * Decode an `AudioBuffer` from a `Blob` or `File`. */ -export async function loadBufferFromBlob( - ctx: AudioContext, - blob: Blob, -): Promise { +export async function loadBufferFromBlob(ctx: AudioContext, blob: Blob): Promise { const arrayBuffer = await blob.arrayBuffer(); return ctx.decodeAudioData(arrayBuffer); } diff --git a/src/emitter.ts b/src/emitter.ts index efb59f2..2e0596d 100644 --- a/src/emitter.ts +++ b/src/emitter.ts @@ -5,16 +5,10 @@ /** A minimal, type-safe event emitter. */ export interface Emitter = Record> { /** Subscribe to an event. Returns an unsubscribe function. */ - on( - event: K, - handler: (data: Events[K]) => void, - ): () => void; + on(event: K, handler: (data: Events[K]) => void): () => void; /** Unsubscribe a handler from an event. */ - off( - event: K, - handler: (data: Events[K]) => void, - ): void; + off(event: K, handler: (data: Events[K]) => void): void; /** Emit an event with data. */ emit(event: K, data: Events[K]): void; @@ -48,10 +42,7 @@ export function createEmitter< } return { - on( - event: K, - handler: (data: Events[K]) => void, - ): () => void { + on(event: K, handler: (data: Events[K]) => void): () => void { const set = getSet(event); set.add(handler as (data: never) => void); return () => { @@ -59,10 +50,7 @@ export function createEmitter< }; }, - off( - event: K, - handler: (data: Events[K]) => void, - ): void { + off(event: K, handler: (data: Events[K]) => void): void { listeners.get(event)?.delete(handler as (data: never) => void); }, diff --git a/src/fade.ts b/src/fade.ts index c43cdc3..cc7a51b 100644 --- a/src/fade.ts +++ b/src/fade.ts @@ -27,10 +27,7 @@ function applyRamp( switch (curve) { case "exponential": - param.exponentialRampToValueAtTime( - Math.max(to, EXP_MIN), - now + duration, - ); + param.exponentialRampToValueAtTime(Math.max(to, EXP_MIN), now + duration); break; case "equal-power": { // Approximate equal-power with a setValueCurveAtTime. @@ -44,7 +41,6 @@ function applyRamp( param.setValueCurveAtTime(values, now, duration); break; } - case "linear": default: param.linearRampToValueAtTime(to, now + duration); break; @@ -54,11 +50,7 @@ function applyRamp( /** * Fade a `GainNode` in from 0 to `target`. */ -export function fadeIn( - gain: GainNode, - target: number, - options?: FadeOptions, -): void { +export function fadeIn(gain: GainNode, target: number, options?: FadeOptions): void { const { duration = 1, curve = "linear" } = options ?? {}; applyRamp(gain, 0, target, duration, curve); } @@ -74,11 +66,7 @@ export function fadeOut(gain: GainNode, options?: FadeOptions): void { /** * Crossfade between two `GainNode`s. */ -export function crossfade( - gainA: GainNode, - gainB: GainNode, - options?: CrossfadeOptions, -): void { +export function crossfade(gainA: GainNode, gainB: GainNode, options?: CrossfadeOptions): void { const { duration = 1, curve = "linear" } = options ?? {}; applyRamp(gainA, gainA.gain.value, 0, duration, curve); applyRamp(gainB, 0, gainA.gain.value || 1, duration, curve); @@ -108,11 +96,7 @@ export function autoFade( } const unsub = playback.on("timeupdate", ({ position }) => { - if ( - fadeOutDuration > 0 && - !fadeOutScheduled && - position >= duration - fadeOutDuration - ) { + if (fadeOutDuration > 0 && !fadeOutScheduled && position >= duration - fadeOutDuration) { fadeOutScheduled = true; applyRamp(gain, gain.gain.value, 0, fadeOutDuration, curve); } diff --git a/src/index.ts b/src/index.ts index f8b9141..d5828b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,86 +2,77 @@ // waa — Composable Web Audio API utilities // --------------------------------------------------------------------------- -// Types -export type { - AutoFadeOptions, - BufferInfo, - ClockOptions, - CreateContextOptions, - CrossfadeOptions, - ExtractPeaksOptions, - FadeCurve, - FadeOptions, - LoadBufferOptions, - PeakPair, - Playback, - PlaybackEventMap, - PlaybackSnapshot, - PlaybackState, - PlayOptions, - ScheduledEvent, - SchedulerOptions, - StretcherSnapshotExtension, -} from "./types.js"; - -// M1: Context -export { createContext, resumeContext, ensureRunning, now } from "./context.js"; - +// M10: Adapters +export { + getSnapshot, + onFrame, + subscribeSnapshot, + whenEnded, + whenPosition, +} from "./adapters.js"; // M2: Buffer export { + getBufferInfo, loadBuffer, loadBufferFromBlob, loadBuffers, - getBufferInfo, } from "./buffer.js"; - -// M3: Play -export { play } from "./play.js"; +// M1: Context +export { createContext, ensureRunning, now, resumeContext } from "./context.js"; +export type { Emitter } from "./emitter.js"; // M4: Emitter export { createEmitter } from "./emitter.js"; -export type { Emitter } from "./emitter.js"; +// M7: Fade +export { autoFade, crossfade, fadeIn, fadeOut } from "./fade.js"; // M5: Nodes export { - createGain, - rampGain, + chain, createAnalyser, - getFrequencyData, - getFrequencyDataByte, + createCompressor, createFilter, + createGain, createPanner, - createCompressor, - chain, disconnectChain, + getFrequencyData, + getFrequencyDataByte, + rampGain, } from "./nodes.js"; - -// M6: Waveform -export { extractPeaks, extractPeakPairs, extractRMS } from "./waveform.js"; - -// M7: Fade -export { fadeIn, fadeOut, crossfade, autoFade } from "./fade.js"; - +// M3: Play +export { play } from "./play.js"; +export type { WaaPlayerOptions } from "./player.js"; +// M11: Player (class-based API) +export { WaaPlayer } from "./player.js"; +export type { Clock, Scheduler } from "./scheduler.js"; // M8: Scheduler & Clock -export { createScheduler, createClock } from "./scheduler.js"; -export type { Scheduler, Clock } from "./scheduler.js"; - +export { createClock, createScheduler } from "./scheduler.js"; // M9: Synth export { - createSineBuffer, - createNoiseBuffer, createClickBuffer, + createNoiseBuffer, + createSineBuffer, } from "./synth.js"; - -// M10: Adapters -export { - getSnapshot, - subscribeSnapshot, - onFrame, - whenEnded, - whenPosition, -} from "./adapters.js"; - -// M11: Player (class-based API) -export { WaaPlayer } from "./player.js"; -export type { WaaPlayerOptions } from "./player.js"; +// Types +export type { + AutoFadeOptions, + BufferInfo, + ClockOptions, + CreateContextOptions, + CrossfadeOptions, + ExtractPeaksOptions, + FadeCurve, + FadeOptions, + LoadBufferOptions, + PeakPair, + Playback, + PlaybackEventMap, + PlaybackSnapshot, + PlaybackState, + PlayOptions, + ScheduledEvent, + SchedulerOptions, + StretcherSnapshotExtension, +} from "./types.js"; +// M6: Waveform +export { extractPeakPairs, extractPeaks, extractRMS } from "./waveform.js"; diff --git a/src/nodes.ts b/src/nodes.ts index f8cb1b0..3a3d230 100644 --- a/src/nodes.ts +++ b/src/nodes.ts @@ -5,10 +5,7 @@ /** * Create a `GainNode` with an optional initial value. */ -export function createGain( - ctx: AudioContext, - initialValue?: number, -): GainNode { +export function createGain(ctx: AudioContext, initialValue?: number): GainNode { const gain = ctx.createGain(); if (initialValue !== undefined) { gain.gain.value = initialValue; @@ -20,11 +17,7 @@ export function createGain( * Smoothly ramp a `GainNode` to a target value over `duration` seconds using * `linearRampToValueAtTime`. This avoids audible clicks when changing volume. */ -export function rampGain( - gain: GainNode, - target: number, - duration: number, -): void { +export function rampGain(gain: GainNode, target: number, duration: number): void { const now = gain.context.currentTime; gain.gain.cancelScheduledValues(now); gain.gain.setValueAtTime(gain.gain.value, now); @@ -87,10 +80,7 @@ export function createFilter( /** * Create a `StereoPannerNode`. */ -export function createPanner( - ctx: AudioContext, - pan?: number, -): StereoPannerNode { +export function createPanner(ctx: AudioContext, pan?: number): StereoPannerNode { const panner = ctx.createStereoPanner(); if (pan !== undefined) { panner.pan.value = pan; diff --git a/src/play.ts b/src/play.ts index 1f3bbea..92bcf56 100644 --- a/src/play.ts +++ b/src/play.ts @@ -3,10 +3,11 @@ // --------------------------------------------------------------------------- import { createEmitter } from "./emitter.js"; +import { calcPlaybackPosition } from "./playback-position.js"; +import { createPlaybackStateManager } from "./playback-state.js"; import type { Playback, PlaybackEventMap, - PlaybackState, PlayOptions, StretcherSnapshotExtension, } from "./types.js"; @@ -25,11 +26,7 @@ import type { * pb.dispose(); * ``` */ -export function play( - ctx: AudioContext, - buffer: AudioBuffer, - options?: PlayOptions, -): Playback { +export function play(ctx: AudioContext, buffer: AudioBuffer, options?: PlayOptions): Playback { const { preservePitch = true } = options ?? {}; // ----- Pitch-preserving mode (WSOLA-based time-stretch) ----- @@ -52,14 +49,23 @@ export function play( const duration = buffer.duration; // ----- mutable internal state ----- - let state: PlaybackState = "stopped"; let sourceNode: AudioBufferSourceNode | null = null; let startedAt = 0; // ctx.currentTime when playback last started/resumed let pausedAt = initialOffset; // position in the buffer (seconds) - let currentRate = initialRate; + let currentRate = initialRate > 0 ? initialRate : 1; let isLooping = loop; - let timerId: ReturnType | null = null; - let disposed = false; + + const sm = createPlaybackStateManager({ + initialState: "stopped", + onStateChange: (next) => emitter.emit("statechange", { state: next }), + onTimerTick: () => { + emitter.emit("timeupdate", { + position: getCurrentTime(), + duration, + }); + }, + timerInterval: timeupdateInterval, + }); // ----- helpers ----- @@ -107,107 +113,76 @@ export function play( function handleEnded() { // If we manually stopped / paused, the handler was already removed. - if (state !== "playing") return; + if (sm.getState() !== "playing") return; if (isLooping) { emitter.emit("loop", undefined as never); return; } - setState("stopped"); + sm.setState("stopped"); pausedAt = 0; - stopTimer(); + sm.stopTimer(); emitter.emit("ended", undefined as never); } - function setState(next: PlaybackState) { - if (state === next) return; - state = next; - emitter.emit("statechange", { state: next }); - } - - function startTimer() { - if (timerId !== null) return; - timerId = setInterval(() => { - if (state !== "playing") return; - emitter.emit("timeupdate", { - position: getCurrentTime(), - duration, - }); - }, timeupdateInterval); - } - - function stopTimer() { - if (timerId !== null) { - clearInterval(timerId); - timerId = null; - } - } - // ----- public API ----- function getCurrentTime(): number { - if (state === "playing") { - const elapsed = (ctx.currentTime - startedAt) * currentRate; - if (isLooping) { - const loopDur = - (loopEnd ?? duration) - (loopStart ?? 0); - return ((elapsed - (loopStart ?? 0)) % loopDur) + (loopStart ?? 0); - } - return Math.min(elapsed, duration); - } - if (state === "paused") return pausedAt; - return 0; + const state = sm.getState(); + const elapsed = state === "playing" ? (ctx.currentTime - startedAt) * currentRate : 0; + return calcPlaybackPosition(state, elapsed, duration, pausedAt, isLooping, loopStart, loopEnd); } function pause() { - if (state !== "playing" || disposed) return; + if (sm.getState() !== "playing" || sm.isDisposed()) return; pausedAt = getCurrentTime(); stopSource(); - stopTimer(); - setState("paused"); + sm.stopTimer(); + sm.setState("paused"); emitter.emit("pause", undefined as never); } function resume() { - if (state !== "paused" || disposed) return; + if (sm.getState() !== "paused" || sm.isDisposed()) return; startSource(pausedAt); - setState("playing"); - startTimer(); + sm.setState("playing"); + sm.startTimer(); emitter.emit("resume", undefined as never); } function togglePlayPause() { - if (state === "playing") pause(); - else if (state === "paused") resume(); + if (sm.getState() === "playing") pause(); + else if (sm.getState() === "paused") resume(); } function seek(position: number) { - if (disposed) return; + if (sm.isDisposed()) return; const clamped = Math.max(0, Math.min(position, duration)); - const wasPlaying = state === "playing"; + const wasPlaying = sm.getState() === "playing"; stopSource(); - stopTimer(); + sm.stopTimer(); pausedAt = clamped; if (wasPlaying) { startSource(clamped); - startTimer(); + sm.startTimer(); } emitter.emit("seek", { position: clamped }); } function stop() { - if (state === "stopped" || disposed) return; + if (sm.getState() === "stopped" || sm.isDisposed()) return; stopSource(); - stopTimer(); + sm.stopTimer(); pausedAt = 0; - setState("stopped"); + sm.setState("stopped"); emitter.emit("stop", undefined as never); } function setPlaybackRate(rate: number) { + if (rate <= 0) return; const position = getCurrentTime(); currentRate = rate; if (sourceNode) { @@ -224,22 +199,21 @@ export function play( } function dispose() { - if (disposed) return; - disposed = true; + if (sm.isDisposed()) return; + sm.markDisposed(); stopSource(); - stopTimer(); emitter.clear(); } // ----- kick off initial playback ----- startSource(initialOffset); - setState("playing"); - startTimer(); + sm.setState("playing"); + sm.startTimer(); emitter.emit("play", undefined as never); return { - getState: () => state, + getState: () => sm.getState(), getCurrentTime, getDuration: () => duration, getProgress: () => (duration > 0 ? getCurrentTime() / duration : 0), @@ -277,100 +251,96 @@ function createStretchedPlayback( const emitter = createEmitter(); const duration = buffer.duration; - let state: PlaybackState = "playing"; - let engineInstance: import("./stretcher/types.js").StretcherEngine | null = - null; - let timerId: ReturnType | null = null; - let disposed = false; - let currentRate = initialRate; + let engineInstance: import("./stretcher/types.js").StretcherEngine | null = null; + let currentRate = initialRate > 0 ? initialRate : 1; let isLooping = loop; let pendingSeek: number | null = null; + const sm = createPlaybackStateManager({ + initialState: "playing", + onStateChange: (next) => emitter.emit("statechange", { state: next }), + onTimerTick: () => { + emitter.emit("timeupdate", { + position: getCurrentTime(), + duration, + }); + }, + timerInterval: timeupdateInterval, + }); + // Emit initial play event emitter.emit("statechange", { state: "playing" }); emitter.emit("play", undefined as never); // Fire-and-forget dynamic import of the stretcher engine - import("./stretcher/engine.js").then(({ createStretcherEngine }) => { - if (disposed) return; - - engineInstance = createStretcherEngine(ctx, buffer, { - tempo: currentRate, - offset: initialOffset, - loop: isLooping, - through, - destination, - timeupdateInterval, - }); - - // Wire stretcher events to playback events - engineInstance.on("buffering", (data) => { - if (disposed) return; - emitter.emit("buffering", data); - }); + import("./stretcher/engine.js") + .then(({ createStretcherEngine }) => { + if (sm.isDisposed()) return; + + engineInstance = createStretcherEngine(ctx, buffer, { + tempo: currentRate, + offset: initialOffset, + loop: isLooping, + through, + destination, + timeupdateInterval, + }); - engineInstance.on("buffered", (data) => { - if (disposed) return; - emitter.emit("buffered", data); - }); + // Wire stretcher events to playback events + engineInstance.on("buffering", (data) => { + if (sm.isDisposed()) return; + emitter.emit("buffering", data); + }); - engineInstance.on("loop", () => { - if (disposed) return; - emitter.emit("loop", undefined as never); - }); + engineInstance.on("buffered", (data) => { + if (sm.isDisposed()) return; + emitter.emit("buffered", data); + }); - engineInstance.on("ended", () => { - if (disposed) return; - state = "stopped"; - stopTimer(); - emitter.emit("statechange", { state: "stopped" }); - emitter.emit("ended", undefined as never); - }); + engineInstance.on("loop", () => { + if (sm.isDisposed()) return; + emitter.emit("loop", undefined as never); + }); - engineInstance.on("error", (data) => { - if (disposed) return; - if (data.fatal) { - state = "stopped"; - emitter.emit("statechange", { state: "stopped" }); + engineInstance.on("ended", () => { + if (sm.isDisposed()) return; + if (sm.getState() === "stopped") return; + sm.stopTimer(); + sm.setState("stopped"); emitter.emit("ended", undefined as never); - } - }); - - // Start engine and timeupdate timer - engineInstance.start(); - startTimer(); + }); - // Apply pending seek if any - if (pendingSeek !== null) { - engineInstance.seek(pendingSeek); - pendingSeek = null; - } + engineInstance.on("error", (data) => { + if (sm.isDisposed()) return; + if (data.fatal) { + sm.setState("stopped"); + emitter.emit("ended", undefined as never); + } + }); - // If we were paused before the engine loaded, pause it - if (state === "paused") { - engineInstance.pause(); - } else if (state === "stopped") { - engineInstance.stop(); - } - }); + // Start engine and timeupdate timer + engineInstance.start(); + sm.startTimer(); - function startTimer() { - if (timerId !== null) return; - timerId = setInterval(() => { - if (state !== "playing" || disposed) return; - emitter.emit("timeupdate", { - position: getCurrentTime(), - duration, - }); - }, timeupdateInterval); - } + // Apply pending seek if any + if (pendingSeek !== null) { + engineInstance.seek(pendingSeek); + pendingSeek = null; + } - function stopTimer() { - if (timerId !== null) { - clearInterval(timerId); - timerId = null; - } - } + // If we were paused before the engine loaded, pause it + if (sm.getState() === "paused") { + engineInstance.pause(); + } else if (sm.getState() === "stopped") { + engineInstance.stop(); + } + }) + .catch(() => { + if (sm.isDisposed()) return; + sm.stopTimer(); + sm.setState("stopped"); + emitter.emit("ended", undefined as never); + }); function getCurrentTime(): number { if (pendingSeek !== null) { @@ -383,30 +353,28 @@ function createStretchedPlayback( } function pause() { - if (state !== "playing" || disposed) return; - state = "paused"; + if (sm.getState() !== "playing" || sm.isDisposed()) return; engineInstance?.pause(); - stopTimer(); - emitter.emit("statechange", { state: "paused" }); + sm.stopTimer(); + sm.setState("paused"); emitter.emit("pause", undefined as never); } function resume() { - if (state !== "paused" || disposed) return; - state = "playing"; + if (sm.getState() !== "paused" || sm.isDisposed()) return; engineInstance?.resume(); - startTimer(); - emitter.emit("statechange", { state: "playing" }); + sm.startTimer(); + sm.setState("playing"); emitter.emit("resume", undefined as never); } function togglePlayPause() { - if (state === "playing") pause(); - else if (state === "paused") resume(); + if (sm.getState() === "playing") pause(); + else if (sm.getState() === "paused") resume(); } function seek(position: number) { - if (disposed) return; + if (sm.isDisposed()) return; const clamped = Math.max(0, Math.min(position, duration)); if (engineInstance) { engineInstance.seek(clamped); @@ -417,15 +385,15 @@ function createStretchedPlayback( } function stop() { - if (state === "stopped" || disposed) return; - state = "stopped"; + if (sm.getState() === "stopped" || sm.isDisposed()) return; engineInstance?.stop(); - stopTimer(); - emitter.emit("statechange", { state: "stopped" }); + sm.stopTimer(); + sm.setState("stopped"); emitter.emit("stop", undefined as never); } function setPlaybackRate(rate: number) { + if (rate <= 0) return; currentRate = rate; if (engineInstance) { engineInstance.setTempo(rate); @@ -438,9 +406,8 @@ function createStretchedPlayback( } function dispose() { - if (disposed) return; - disposed = true; - stopTimer(); + if (sm.isDisposed()) return; + sm.markDisposed(); engineInstance?.dispose(); emitter.clear(); } @@ -453,7 +420,7 @@ function createStretchedPlayback( const playback: Playback & { _getStretcherSnapshot: typeof _getStretcherSnapshot; } = { - getState: () => state, + getState: () => sm.getState(), getCurrentTime, getDuration: () => duration, getProgress: () => (duration > 0 ? getCurrentTime() / duration : 0), diff --git a/src/playback-position.ts b/src/playback-position.ts new file mode 100644 index 0000000..f5ba0c3 --- /dev/null +++ b/src/playback-position.ts @@ -0,0 +1,49 @@ +// --------------------------------------------------------------------------- +// Pure functions for playback position calculation +// --------------------------------------------------------------------------- + +/** + * Calculate the current position within a loop region. + * + * Uses a true modulo (always non-negative) to avoid the JS `%` sign-preservation + * issue where `elapsed < loopStart` would produce a value outside the loop region. + * + * Returns `null` when the loop region is invalid (loopDur <= 0) so the caller + * can fall back to non-loop behaviour. + */ +export function calcLoopPosition( + elapsed: number, + loopStart: number, + loopEnd: number, +): number | null { + const loopDur = loopEnd - loopStart; + if (loopDur <= 0) return null; + const offset = elapsed - loopStart; + return (((offset % loopDur) + loopDur) % loopDur) + loopStart; +} + +/** + * Calculate the playback position for a given state. + * + * Pure function — no side effects, no dependency on AudioContext. + */ +export function calcPlaybackPosition( + state: "playing" | "paused" | "stopped", + elapsed: number, + duration: number, + pausedAt: number, + isLooping: boolean, + loopStart: number | undefined, + loopEnd: number | undefined, +): number { + if (state === "paused") return pausedAt; + if (state === "stopped") return 0; + + // state === "playing" + if (isLooping) { + const looped = calcLoopPosition(elapsed, loopStart ?? 0, loopEnd ?? duration); + // Invalid loop region → fall back to non-loop clamping + if (looped !== null) return looped; + } + return Math.min(Math.max(elapsed, 0), duration); +} diff --git a/src/playback-state.ts b/src/playback-state.ts new file mode 100644 index 0000000..d13f00e --- /dev/null +++ b/src/playback-state.ts @@ -0,0 +1,67 @@ +// --------------------------------------------------------------------------- +// Shared playback state management (used by both normal and stretched modes) +// --------------------------------------------------------------------------- + +import type { PlaybackState } from "./types.js"; + +export interface PlaybackStateManager { + getState(): PlaybackState; + setState(next: PlaybackState): boolean; + startTimer(): void; + stopTimer(): void; + isDisposed(): boolean; + markDisposed(): void; +} + +/** + * Create a reusable state manager that encapsulates the setState / startTimer / + * stopTimer / dispose pattern shared between normal and stretched playback. + */ +export function createPlaybackStateManager(opts: { + initialState: PlaybackState; + onStateChange: (state: PlaybackState) => void; + onTimerTick: () => void; + timerInterval: number; +}): PlaybackStateManager { + let state: PlaybackState = opts.initialState; + let timerId: ReturnType | null = null; + let disposed = false; + + function getState(): PlaybackState { + return state; + } + + /** Returns `true` if state actually changed. */ + function setState(next: PlaybackState): boolean { + if (state === next) return false; + state = next; + opts.onStateChange(next); + return true; + } + + function startTimer(): void { + if (timerId !== null) return; + timerId = setInterval(() => { + if (state !== "playing" || disposed) return; + opts.onTimerTick(); + }, opts.timerInterval); + } + + function stopTimer(): void { + if (timerId !== null) { + clearInterval(timerId); + timerId = null; + } + } + + function isDisposed(): boolean { + return disposed; + } + + function markDisposed(): void { + disposed = true; + stopTimer(); + } + + return { getState, setState, startTimer, stopTimer, isDisposed, markDisposed }; +} diff --git a/src/player.ts b/src/player.ts index 0554be9..616d4f5 100644 --- a/src/player.ts +++ b/src/player.ts @@ -1,55 +1,40 @@ -import { createContext, resumeContext, ensureRunning, now } from "./context.js"; +import { getSnapshot, onFrame, subscribeSnapshot, whenEnded, whenPosition } from "./adapters.js"; +import { getBufferInfo, loadBuffer, loadBufferFromBlob, loadBuffers } from "./buffer.js"; +import { createContext, ensureRunning, now, resumeContext } from "./context.js"; +import { autoFade, crossfade, fadeIn, fadeOut } from "./fade.js"; import { - loadBuffer, - loadBufferFromBlob, - loadBuffers, - getBufferInfo, -} from "./buffer.js"; -import { play } from "./play.js"; -import { - createGain as createGainNode, - rampGain, + chain, createAnalyser as createAnalyserNode, - getFrequencyData, - getFrequencyDataByte, + createCompressor as createCompressorNode, createFilter as createFilterNode, + createGain as createGainNode, createPanner as createPannerNode, - createCompressor as createCompressorNode, - chain, disconnectChain, + getFrequencyData, + getFrequencyDataByte, + rampGain, } from "./nodes.js"; -import { extractPeaks, extractPeakPairs, extractRMS } from "./waveform.js"; -import { fadeIn, fadeOut, crossfade, autoFade } from "./fade.js"; -import { createScheduler, createClock } from "./scheduler.js"; -import { - createSineBuffer, - createNoiseBuffer, - createClickBuffer, -} from "./synth.js"; -import { - getSnapshot, - subscribeSnapshot, - onFrame, - whenEnded, - whenPosition, -} from "./adapters.js"; +import { play } from "./play.js"; +import type { Clock, Scheduler } from "./scheduler.js"; +import { createClock, createScheduler } from "./scheduler.js"; +import { createClickBuffer, createNoiseBuffer, createSineBuffer } from "./synth.js"; import type { + AutoFadeOptions, + BufferInfo, + ClockOptions, CreateContextOptions, + CrossfadeOptions, + ExtractPeaksOptions, + FadeOptions, LoadBufferOptions, - BufferInfo, - PlayOptions, + PeakPair, Playback, PlaybackSnapshot, - ExtractPeaksOptions, - PeakPair, - FadeOptions, - CrossfadeOptions, - AutoFadeOptions, + PlayOptions, SchedulerOptions, - ClockOptions, } from "./types.js"; -import type { Scheduler, Clock } from "./scheduler.js"; +import { extractPeakPairs, extractPeaks, extractRMS } from "./waveform.js"; export interface WaaPlayerOptions extends CreateContextOptions {} @@ -58,10 +43,7 @@ export class WaaPlayer { private readonly _ownsContext: boolean; constructor(ctxOrOptions?: AudioContext | WaaPlayerOptions) { - if ( - ctxOrOptions && - typeof (ctxOrOptions as AudioContext).createGain === "function" - ) { + if (ctxOrOptions && typeof (ctxOrOptions as AudioContext).createGain === "function") { this.ctx = ctxOrOptions as AudioContext; this._ownsContext = false; } else { @@ -114,10 +96,7 @@ export class WaaPlayer { return createGainNode(this.ctx, initialValue); } - createAnalyser(options?: { - fftSize?: number; - smoothingTimeConstant?: number; - }): AnalyserNode { + createAnalyser(options?: { fftSize?: number; smoothingTimeConstant?: number }): AnalyserNode { return createAnalyserNode(this.ctx, options); } @@ -170,17 +149,11 @@ export class WaaPlayer { return extractPeaks(buffer, options); } - extractPeakPairs( - buffer: AudioBuffer, - options?: ExtractPeaksOptions, - ): PeakPair[] { + extractPeakPairs(buffer: AudioBuffer, options?: ExtractPeaksOptions): PeakPair[] { return extractPeakPairs(buffer, options); } - extractRMS( - buffer: AudioBuffer, - options?: ExtractPeaksOptions & { channel?: number }, - ): number[] { + extractRMS(buffer: AudioBuffer, options?: ExtractPeaksOptions & { channel?: number }): number[] { return extractRMS(buffer, options); } @@ -194,19 +167,11 @@ export class WaaPlayer { fadeOut(gain, options); } - crossfade( - gainA: GainNode, - gainB: GainNode, - options?: CrossfadeOptions, - ): void { + crossfade(gainA: GainNode, gainB: GainNode, options?: CrossfadeOptions): void { crossfade(gainA, gainB, options); } - autoFade( - playback: Playback, - gain: GainNode, - options?: AutoFadeOptions, - ): () => void { + autoFade(playback: Playback, gain: GainNode, options?: AutoFadeOptions): () => void { return autoFade(playback, gain, options); } @@ -244,10 +209,7 @@ export class WaaPlayer { return subscribeSnapshot(playback, callback); } - onFrame( - playback: Playback, - callback: (snapshot: PlaybackSnapshot) => void, - ): () => void { + onFrame(playback: Playback, callback: (snapshot: PlaybackSnapshot) => void): () => void { return onFrame(playback, callback); } diff --git a/src/scheduler.ts b/src/scheduler.ts index c93f997..b3b1440 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -27,10 +27,7 @@ export interface Scheduler { * Uses `setInterval` to check upcoming events and fire callbacks * slightly before their scheduled time, enabling sample-accurate timing. */ -export function createScheduler( - ctx: AudioContext, - options?: SchedulerOptions, -): Scheduler { +export function createScheduler(ctx: AudioContext, options?: SchedulerOptions): Scheduler { const { lookahead = 0.1, interval = 25 } = options ?? {}; const events: ScheduledEvent[] = []; @@ -92,10 +89,7 @@ export interface Clock { /** * Create a BPM-based clock tied to an `AudioContext`. */ -export function createClock( - ctx: AudioContext, - options?: ClockOptions, -): Clock { +export function createClock(ctx: AudioContext, options?: ClockOptions): Clock { let bpm = options?.bpm ?? 120; const startTime = ctx.currentTime; diff --git a/src/stretcher/buffer-monitor.ts b/src/stretcher/buffer-monitor.ts index 3ac84b3..277f36c 100644 --- a/src/stretcher/buffer-monitor.ts +++ b/src/stretcher/buffer-monitor.ts @@ -3,9 +3,9 @@ // --------------------------------------------------------------------------- import { + BUFFER_CRITICAL_SEC, BUFFER_HEALTHY_SEC, BUFFER_LOW_SEC, - BUFFER_CRITICAL_SEC, BUFFER_RESUME_SEC, CHUNK_DURATION_SEC, } from "./constants.js"; @@ -14,19 +14,14 @@ import type { BufferHealth, BufferMonitor, BufferMonitorOptions, ChunkInfo } fro /** * Create a buffer health monitor with hysteresis. */ -export function createBufferMonitor( - options?: Partial, -): BufferMonitor { +export function createBufferMonitor(options?: Partial): BufferMonitor { const healthySec = options?.healthySec ?? BUFFER_HEALTHY_SEC; const lowSec = options?.lowSec ?? BUFFER_LOW_SEC; const criticalSec = options?.criticalSec ?? BUFFER_CRITICAL_SEC; const resumeSec = options?.resumeSec ?? BUFFER_RESUME_SEC; const chunkDurSec = options?.chunkDurationSec ?? CHUNK_DURATION_SEC; - function getAheadSeconds( - currentChunkIndex: number, - chunks: ChunkInfo[], - ): number { + function getAheadSeconds(currentChunkIndex: number, chunks: ChunkInfo[]): number { let aheadSec = 0; for (let i = currentChunkIndex; i < chunks.length; i++) { const chunk = chunks[i]; @@ -36,10 +31,7 @@ export function createBufferMonitor( return aheadSec; } - function getHealth( - currentChunkIndex: number, - chunks: ChunkInfo[], - ): BufferHealth { + function getHealth(currentChunkIndex: number, chunks: ChunkInfo[]): BufferHealth { const ahead = getAheadSeconds(currentChunkIndex, chunks); if (ahead >= healthySec) return "healthy"; @@ -48,10 +40,7 @@ export function createBufferMonitor( return "empty"; } - function shouldEnterBuffering( - currentChunkIndex: number, - chunks: ChunkInfo[], - ): boolean { + function shouldEnterBuffering(currentChunkIndex: number, chunks: ChunkInfo[]): boolean { const ahead = getAheadSeconds(currentChunkIndex, chunks); if (ahead >= criticalSec) return false; @@ -66,10 +55,7 @@ export function createBufferMonitor( return ahead < criticalSec; } - function shouldExitBuffering( - currentChunkIndex: number, - chunks: ChunkInfo[], - ): boolean { + function shouldExitBuffering(currentChunkIndex: number, chunks: ChunkInfo[]): boolean { // カレントチャンクが ready でなければ buffering を抜けない const currentChunk = chunks[currentChunkIndex]; if (!currentChunk || currentChunk.state !== "ready") return false; @@ -82,9 +68,7 @@ export function createBufferMonitor( if (nextChunk && nextChunk.state === "ready") return true; // If all chunks are done - const allReady = chunks.every( - (c) => c.state === "ready" || c.state === "skipped", - ); + const allReady = chunks.every((c) => c.state === "ready" || c.state === "skipped"); if (allReady) return true; return false; diff --git a/src/stretcher/chunk-player.ts b/src/stretcher/chunk-player.ts index eff206e..9bd7975 100644 --- a/src/stretcher/chunk-player.ts +++ b/src/stretcher/chunk-player.ts @@ -3,6 +3,7 @@ // --------------------------------------------------------------------------- import { CROSSFADE_SEC, LOOKAHEAD_INTERVAL_MS, LOOKAHEAD_THRESHOLD_SEC } from "./constants.js"; +import { calcTransitionDelay } from "./transition-timing.js"; import type { ChunkPlayer, ChunkPlayerOptions } from "./types.js"; const CURVE_LENGTH = 256; @@ -11,7 +12,7 @@ function createEqualPowerCurve(fadeIn: boolean): Float32Array { const curve = new Float32Array(CURVE_LENGTH); for (let i = 0; i < CURVE_LENGTH; i++) { const t = i / (CURVE_LENGTH - 1); - curve[i] = fadeIn ? Math.sin(t * Math.PI / 2) : Math.cos(t * Math.PI / 2); + curve[i] = fadeIn ? Math.sin((t * Math.PI) / 2) : Math.cos((t * Math.PI) / 2); } return curve; } @@ -22,10 +23,7 @@ const fadeOutCurve = createEqualPowerCurve(false); /** * Create a chunk player that manages gapless playback of converted chunks. */ -export function createChunkPlayer( - ctx: AudioContext, - options: ChunkPlayerOptions, -): ChunkPlayer { +export function createChunkPlayer(ctx: AudioContext, options: ChunkPlayerOptions): ChunkPlayer { const destination = options.destination ?? ctx.destination; const through = options.through ?? []; const crossfadeSec = options.crossfadeSec ?? CROSSFADE_SEC; @@ -39,6 +37,7 @@ export function createChunkPlayer( let playStartCtxTime = 0; // ctx.currentTime when playback started let playStartOffset = 0; // offset within the chunk at start let currentChunkDuration = 0; + let nextStartCtxTime = 0; // scheduleNext で次ソースの AudioContext 開始時刻 let paused = false; let pausedPosition = 0; let stopped = true; @@ -62,10 +61,7 @@ export function createChunkPlayer( } } - function createSourceFromBuffer( - buffer: AudioBuffer, - gain: GainNode, - ): AudioBufferSourceNode { + function createSourceFromBuffer(buffer: AudioBuffer, gain: GainNode): AudioBufferSourceNode { const src = ctx.createBufferSource(); src.buffer = buffer; src.connect(gain); @@ -135,16 +131,50 @@ export function createChunkPlayer( } } + function doTransition(buffer: AudioBuffer, startCtxTime: number): void { + stopCurrentSource(); + currentSource = nextSource; + currentGain = nextGain; + nextSource = null; + nextGain = null; + + currentChunkDuration = buffer.duration; + playStartOffset = 0; + playStartCtxTime = startCtxTime; + + if (currentSource) { + currentSource.onended = handleCurrentSourceEnded; + } + + onTransition?.(); + } + + function handleCurrentSourceEnded(): void { + if (disposed || paused || stopped) return; + if (nextSource) { + const buf = nextSource.buffer; + if (!buf) { + onChunkEnded?.(); + return; + } + cancelTransition(); + doTransition(buf, nextStartCtxTime); + } else { + onChunkEnded?.(); + } + } + function getElapsedInChunk(): number { if (paused) return pausedPosition; if (stopped) return 0; - return (ctx.currentTime - playStartCtxTime) + playStartOffset; + return ctx.currentTime - playStartCtxTime + playStartOffset; } function playChunk( buffer: AudioBuffer, _startTime: number, - offsetInChunk: number = 0, + offsetInChunk = 0, + skipFadeIn = false, ): void { cancelTransition(); stopCurrentSource(); @@ -158,16 +188,12 @@ export function createChunkPlayer( paused = false; stopped = false; - currentSource.onended = () => { - if (!disposed && !paused && !stopped) { - onChunkEnded?.(); - } - }; + currentSource.onended = handleCurrentSourceEnded; currentSource.start(0, offsetInChunk); // Apply equal-power fade-in - if (crossfadeSec > 0) { + if (crossfadeSec > 0 && !skipFadeIn) { currentGain.gain.setValueCurveAtTime(fadeInCurve, ctx.currentTime, crossfadeSec); } @@ -182,40 +208,24 @@ export function createChunkPlayer( nextGain = ctx.createGain(); nextSource = createSourceFromBuffer(buffer, nextGain); - nextSource.onended = () => { - if (!disposed && !paused && !stopped) { - onChunkEnded?.(); - } - }; + nextSource.onended = handleCurrentSourceEnded; - nextSource.start(startTime - crossfadeSec); + nextStartCtxTime = startTime - crossfadeSec; + nextSource.start(nextStartCtxTime); // Equal-power crossfade: fade out current, fade in next if (crossfadeSec > 0 && currentGain) { - currentGain.gain.setValueCurveAtTime(fadeOutCurve, startTime - crossfadeSec, crossfadeSec); - nextGain.gain.setValueCurveAtTime(fadeInCurve, startTime - crossfadeSec, crossfadeSec); + currentGain.gain.setValueCurveAtTime(fadeOutCurve, nextStartCtxTime, crossfadeSec); + nextGain.gain.setValueCurveAtTime(fadeInCurve, nextStartCtxTime, crossfadeSec); } // After transition, promote next to current - const transitionDelay = Math.max( - 0, - (startTime - ctx.currentTime) * 1000 + 50, - ); + const transitionDelay = calcTransitionDelay(startTime, ctx.currentTime); cancelTransition(); transitionTimerId = setTimeout(() => { transitionTimerId = null; - if (disposed) return; - stopCurrentSource(); - currentSource = nextSource; - currentGain = nextGain; - nextSource = null; - nextGain = null; - - currentChunkDuration = buffer.duration; - playStartOffset = 0; - playStartCtxTime = startTime - crossfadeSec; - - onTransition?.(); + if (disposed || !nextSource) return; + doTransition(buffer, nextStartCtxTime); }, transitionDelay); } diff --git a/src/stretcher/chunk-splitter.ts b/src/stretcher/chunk-splitter.ts index e9676f2..c541793 100644 --- a/src/stretcher/chunk-splitter.ts +++ b/src/stretcher/chunk-splitter.ts @@ -60,9 +60,7 @@ export function splitIntoChunks( // Overlap regions const overlapBefore = isFirst ? 0 : Math.min(overlapSamples, start); - const overlapAfter = isLast - ? 0 - : Math.min(overlapSamples, totalSamples - nominalEnd); + const overlapAfter = isLast ? 0 : Math.min(overlapSamples, totalSamples - nominalEnd); // Actual input range includes overlap const inputStart = start - overlapBefore; @@ -91,19 +89,14 @@ export function splitIntoChunks( /** * Extract channel data for a specific chunk from an AudioBuffer. */ -export function extractChunkData( - buffer: AudioBuffer, - chunk: ChunkInfo, -): Float32Array[] { +export function extractChunkData(buffer: AudioBuffer, chunk: ChunkInfo): Float32Array[] { const channels: Float32Array[] = []; const length = chunk.inputEndSample - chunk.inputStartSample; for (let ch = 0; ch < buffer.numberOfChannels; ch++) { const fullChannel = buffer.getChannelData(ch); const chunkData = new Float32Array(length); - chunkData.set( - fullChannel.subarray(chunk.inputStartSample, chunk.inputEndSample), - ); + chunkData.set(fullChannel.subarray(chunk.inputStartSample, chunk.inputEndSample)); channels.push(chunkData); } @@ -113,10 +106,7 @@ export function extractChunkData( /** * Get the chunk index that contains the given sample position. */ -export function getChunkIndexForSample( - chunks: ChunkInfo[], - sample: number, -): number { +export function getChunkIndexForSample(chunks: ChunkInfo[], sample: number): number { for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]!; const nominalStart = chunk.inputStartSample + chunk.overlapBefore; diff --git a/src/stretcher/constants.ts b/src/stretcher/constants.ts index d436236..8eb8b1c 100644 --- a/src/stretcher/constants.ts +++ b/src/stretcher/constants.ts @@ -41,4 +41,5 @@ export const ESTIMATOR_WINDOW_SIZE = 10; // Playback lookahead export const LOOKAHEAD_INTERVAL_MS = 200; -export const LOOKAHEAD_THRESHOLD_SEC = 1.5; +export const LOOKAHEAD_THRESHOLD_SEC = 3.0; +export const PROACTIVE_SCHEDULE_THRESHOLD_SEC = 5.0; diff --git a/src/stretcher/conversion-scheduler.ts b/src/stretcher/conversion-scheduler.ts index c647b36..6df68dd 100644 --- a/src/stretcher/conversion-scheduler.ts +++ b/src/stretcher/conversion-scheduler.ts @@ -3,15 +3,15 @@ // --------------------------------------------------------------------------- import { - PRIORITY_FORWARD_WEIGHT, - PRIORITY_BACKWARD_WEIGHT, CANCEL_DISTANCE_THRESHOLD, - MAX_CHUNK_RETRIES, + CHUNK_DURATION_SEC, KEEP_AHEAD_CHUNKS, KEEP_AHEAD_SECONDS, KEEP_BEHIND_CHUNKS, KEEP_BEHIND_SECONDS, - CHUNK_DURATION_SEC, + MAX_CHUNK_RETRIES, + PRIORITY_BACKWARD_WEIGHT, + PRIORITY_FORWARD_WEIGHT, } from "./constants.js"; import { createPriorityQueue } from "./priority-queue.js"; import type { @@ -41,16 +41,13 @@ export function createConversionScheduler( ): ConversionScheduler { const forwardWeight = options?.forwardWeight ?? PRIORITY_FORWARD_WEIGHT; const backwardWeight = options?.backwardWeight ?? PRIORITY_BACKWARD_WEIGHT; - const cancelDistThreshold = - options?.cancelDistanceThreshold ?? CANCEL_DISTANCE_THRESHOLD; - const keepAhead = options?.keepAheadChunks ?? Math.max( - KEEP_AHEAD_CHUNKS, - Math.ceil(KEEP_AHEAD_SECONDS / CHUNK_DURATION_SEC), - ); - const keepBehind = options?.keepBehindChunks ?? Math.max( - KEEP_BEHIND_CHUNKS, - Math.ceil(KEEP_BEHIND_SECONDS / CHUNK_DURATION_SEC), - ); + const cancelDistThreshold = options?.cancelDistanceThreshold ?? CANCEL_DISTANCE_THRESHOLD; + const keepAhead = + options?.keepAheadChunks ?? + Math.max(KEEP_AHEAD_CHUNKS, Math.ceil(KEEP_AHEAD_SECONDS / CHUNK_DURATION_SEC)); + const keepBehind = + options?.keepBehindChunks ?? + Math.max(KEEP_BEHIND_CHUNKS, Math.ceil(KEEP_BEHIND_SECONDS / CHUNK_DURATION_SEC)); function isInActiveWindow(chunkIndex: number, playheadIndex: number): boolean { const dist = chunkIndex - playheadIndex; @@ -62,9 +59,7 @@ export function createConversionScheduler( let previousTempoCache: TempoCache | null = null; let disposed = false; - const queue = createPriorityQueue( - (a, b) => a.priority - b.priority, - ); + const queue = createPriorityQueue((a, b) => a.priority - b.priority); function calcPriority(chunkIndex: number, playheadIndex: number): number { const distance = chunkIndex - playheadIndex; @@ -79,11 +74,7 @@ export function createConversionScheduler( queue.clear(); for (const chunk of chunks) { - if ( - chunk.state === "pending" || - chunk.state === "queued" || - chunk.state === "failed" - ) { + if (chunk.state === "pending" || chunk.state === "queued" || chunk.state === "failed") { chunk.priority = calcPriority(chunk.index, playheadIndex); chunk.state = "queued"; queue.enqueue(chunk); @@ -126,12 +117,7 @@ export function createConversionScheduler( nextChunk.state = "converting"; const data = extractChunkData(nextChunk.index); - workerManager.postConvert( - nextChunk.index, - data, - currentTempo, - sampleRate, - ); + workerManager.postConvert(nextChunk.index, data, currentTempo, sampleRate); } } @@ -140,8 +126,13 @@ export function createConversionScheduler( outputData: Float32Array[], outputLength: number, ): void { + if (disposed) return; const chunk = chunks[chunkIndex]; if (!chunk) return; + if (chunk.state !== "converting") { + dispatchNext(); + return; + } chunk.state = "ready"; chunk.outputBuffer = outputData; chunk.outputLength = outputLength; @@ -150,6 +141,7 @@ export function createConversionScheduler( } function handleError(chunkIndex: number, error: string): void { + if (disposed) return; const chunk = chunks[chunkIndex]; if (!chunk) return; diff --git a/src/stretcher/engine.ts b/src/stretcher/engine.ts index 9e83536..33c87d6 100644 --- a/src/stretcher/engine.ts +++ b/src/stretcher/engine.ts @@ -3,14 +3,24 @@ // --------------------------------------------------------------------------- import { createEmitter } from "../emitter.js"; -import { CHUNK_DURATION_SEC, OVERLAP_SEC, CROSSFADE_SEC, KEEP_AHEAD_CHUNKS, KEEP_AHEAD_SECONDS, KEEP_BEHIND_CHUNKS, KEEP_BEHIND_SECONDS, WORKER_POOL_SIZE, LOOKAHEAD_THRESHOLD_SEC } from "./constants.js"; -import { splitIntoChunks, extractChunkData, getChunkIndexForTime } from "./chunk-splitter.js"; -import { createWorkerManager } from "./worker-manager.js"; -import { createMainThreadProcessor } from "./main-thread-processor.js"; -import { createConversionScheduler } from "./conversion-scheduler.js"; -import { createChunkPlayer } from "./chunk-player.js"; import { createBufferMonitor } from "./buffer-monitor.js"; +import { createChunkPlayer } from "./chunk-player.js"; +import { extractChunkData, getChunkIndexForTime, splitIntoChunks } from "./chunk-splitter.js"; +import { + CHUNK_DURATION_SEC, + CROSSFADE_SEC, + KEEP_AHEAD_CHUNKS, + KEEP_AHEAD_SECONDS, + KEEP_BEHIND_CHUNKS, + KEEP_BEHIND_SECONDS, + OVERLAP_SEC, + PROACTIVE_SCHEDULE_THRESHOLD_SEC, + WORKER_POOL_SIZE, +} from "./constants.js"; import { createConversionEstimator } from "./conversion-estimator.js"; +import { createConversionScheduler } from "./conversion-scheduler.js"; +import { createMainThreadProcessor } from "./main-thread-processor.js"; +import { calcPositionInOriginalBuffer } from "./position-calc.js"; import type { ChunkInfo, StretcherEngine, @@ -22,11 +32,12 @@ import type { WorkerManager, WorkerResponse, } from "./types.js"; +import { createWorkerManager } from "./worker-manager.js"; /** * Trim overlap regions from WSOLA output so adjacent chunks don't double-play. */ -function trimOverlap( +export function trimOverlap( outputData: Float32Array[], outputLength: number, chunk: ChunkInfo, @@ -38,7 +49,7 @@ function trimOverlap( } const ratio = outputLength / inputLength; - const crossfadeKeep = Math.round(CROSSFADE_SEC * sampleRate); + const crossfadeKeep = Math.round(CROSSFADE_SEC * sampleRate * Math.min(1, ratio)); const overlapBeforeOutput = Math.round(chunk.overlapBefore * ratio); const overlapAfterOutput = Math.round(chunk.overlapAfter * ratio); const keepBefore = chunk.overlapBefore > 0 ? Math.min(crossfadeKeep, overlapBeforeOutput) : 0; @@ -51,7 +62,7 @@ function trimOverlap( } return { - data: outputData.map(ch => ch.slice(trimStart, trimStart + newLength)), + data: outputData.map((ch) => ch.slice(trimStart, trimStart + newLength)), length: newLength, }; } @@ -88,18 +99,19 @@ export function createStretcherEngine( let bufferingStartTime = 0; let currentChunkIndex = 0; let bufferingResumePosition: number | null = null; + let expectedTransitionFrom: number | null = null; + let nextChunkScheduledIndex: number | null = null; + let pendingTempoChange = false; // Memory management window const keepAhead = Math.max(KEEP_AHEAD_CHUNKS, Math.ceil(KEEP_AHEAD_SECONDS / CHUNK_DURATION_SEC)); - const keepBehind = Math.max(KEEP_BEHIND_CHUNKS, Math.ceil(KEEP_BEHIND_SECONDS / CHUNK_DURATION_SEC)); + const keepBehind = Math.max( + KEEP_BEHIND_CHUNKS, + Math.ceil(KEEP_BEHIND_SECONDS / CHUNK_DURATION_SEC), + ); // Split buffer into chunks - const chunks = splitIntoChunks( - buffer.length, - sampleRate, - CHUNK_DURATION_SEC, - OVERLAP_SEC, - ); + const chunks = splitIntoChunks(buffer.length, sampleRate, CHUNK_DURATION_SEC, OVERLAP_SEC); // Estimator const estimator = createConversionEstimator(); @@ -124,11 +136,7 @@ export function createStretcherEngine( chunk, sampleRate, ); - schedulerInternal._handleResult( - response.chunkIndex, - trimmed.data, - trimmed.length, - ); + schedulerInternal._handleResult(response.chunkIndex, trimmed.data, trimmed.length); } } else if (response.type === "cancelled") { const chunk = chunks[response.chunkIndex]; @@ -142,10 +150,7 @@ export function createStretcherEngine( function handleWorkerError(response: WorkerResponse): void { if (disposed) return; if (response.type === "error") { - schedulerInternal._handleError( - response.chunkIndex, - response.error ?? "Unknown error", - ); + schedulerInternal._handleError(response.chunkIndex, response.error ?? "Unknown error"); } } @@ -190,30 +195,36 @@ export function createStretcherEngine( advanceToNextChunk(); }); + function tryScheduleNext(nextIdx: number): void { + if (nextChunkScheduledIndex === nextIdx) return; + if (nextIdx >= chunks.length) return; + const nextChunk = chunks[nextIdx]; + if (!nextChunk || nextChunk.state !== "ready" || !nextChunk.outputBuffer) return; + if (chunkPlayer.hasNextScheduled()) return; + const curChunk = chunks[currentChunkIndex]; + const curOutputDuration = curChunk ? curChunk.outputLength / sampleRate : 0; + const elapsed = chunkPlayer.getCurrentPosition(); + const remaining = curOutputDuration - elapsed; + if (remaining <= 0) return; + const audioBuffer = createAudioBufferFromChunk(nextChunk); + if (!audioBuffer) return; + const startTime = ctx.currentTime + remaining; + expectedTransitionFrom = currentChunkIndex; + chunkPlayer.scheduleNext(audioBuffer, startTime); + nextChunkScheduledIndex = nextIdx; + } + chunkPlayer.setOnNeedNext(() => { if (disposed) return; - const nextIdx = currentChunkIndex + 1; - if (nextIdx < chunks.length) { - const nextChunk = chunks[nextIdx]!; - if (nextChunk.state === "ready" && nextChunk.outputBuffer) { - const audioBuffer = createAudioBufferFromChunk(nextChunk); - if (audioBuffer) { - const curChunk = chunks[currentChunkIndex]; - const curOutputDuration = curChunk - ? curChunk.outputLength / sampleRate - : 0; - const elapsed = chunkPlayer.getCurrentPosition(); - const remaining = curOutputDuration - elapsed; - const startTime = ctx.currentTime + Math.max(0, remaining); - chunkPlayer.scheduleNext(audioBuffer, startTime); - } - } - } + tryScheduleNext(currentChunkIndex + 1); }); chunkPlayer.setOnTransition(() => { if (disposed) return; - // scheduleNext の transition 完了: chunk N+1 が current に昇格した + if (expectedTransitionFrom !== null && currentChunkIndex !== expectedTransitionFrom) { + return; + } + nextChunkScheduledIndex = null; const nextIdx = currentChunkIndex + 1; if (nextIdx < chunks.length) { currentChunkIndex = nextIdx; @@ -240,24 +251,13 @@ export function createStretcherEngine( // Proactively schedule next chunk when it becomes ready (background tab resilience) if (phase === "playing" && chunkIndex === currentChunkIndex + 1) { - if (!chunkPlayer.hasNextScheduled()) { - const curChunk = chunks[currentChunkIndex]; - const curOutputDuration = curChunk - ? curChunk.outputLength / sampleRate - : 0; - const elapsed = chunkPlayer.getCurrentPosition(); - const remaining = curOutputDuration - elapsed; - - if (remaining <= LOOKAHEAD_THRESHOLD_SEC) { - const nextChunk = chunks[chunkIndex]; - if (nextChunk && nextChunk.outputBuffer) { - const audioBuffer = createAudioBufferFromChunk(nextChunk); - if (audioBuffer) { - const startTime = ctx.currentTime + Math.max(0, remaining); - chunkPlayer.scheduleNext(audioBuffer, startTime); - } - } - } + const curChunk = chunks[currentChunkIndex]; + const curOutputDuration = curChunk ? curChunk.outputLength / sampleRate : 0; + const elapsed = chunkPlayer.getCurrentPosition(); + const remaining = curOutputDuration - elapsed; + + if (remaining <= PROACTIVE_SCHEDULE_THRESHOLD_SEC) { + tryScheduleNext(chunkIndex); } } @@ -285,11 +285,7 @@ export function createStretcherEngine( if (!chunk.outputBuffer || chunk.outputLength === 0) return null; const numChannels = chunk.outputBuffer.length; - const audioBuf = ctx.createBuffer( - numChannels, - chunk.outputLength, - sampleRate, - ); + const audioBuf = ctx.createBuffer(numChannels, chunk.outputLength, sampleRate); for (let ch = 0; ch < numChannels; ch++) { const channelData = chunk.outputBuffer[ch]!; @@ -299,14 +295,14 @@ export function createStretcherEngine( return audioBuf; } - function playCurrentChunk(offsetInBuffer: number = 0): void { + function playCurrentChunk(offsetInBuffer = 0, skipFadeIn = false): void { const chunk = chunks[currentChunkIndex]; if (!chunk || chunk.state !== "ready" || !chunk.outputBuffer) return; const audioBuf = createAudioBufferFromChunk(chunk); if (!audioBuf) return; - chunkPlayer.playChunk(audioBuf, ctx.currentTime, offsetInBuffer); + chunkPlayer.playChunk(audioBuf, ctx.currentTime, offsetInBuffer, skipFadeIn); } function advanceToNextChunk(): void { @@ -338,6 +334,7 @@ export function createStretcherEngine( } currentChunkIndex = nextIdx; + nextChunkScheduledIndex = null; scheduler.updatePriorities(currentChunkIndex); const chunk = chunks[currentChunkIndex]; @@ -353,14 +350,13 @@ export function createStretcherEngine( evictDistantChunks(); } - function enterBuffering( - reason: "initial" | "seek" | "tempo-change" | "underrun", - ): void { + function enterBuffering(reason: "initial" | "seek" | "tempo-change" | "underrun"): void { if (phase === "ended") return; // Allow re-entering buffering for tempo-change and seek even if already buffering if (phase === "buffering" && reason !== "tempo-change" && reason !== "seek") return; phase = "buffering"; bufferingStartTime = performance.now(); + nextChunkScheduledIndex = null; chunkPlayer.pause(); emitter.emit("buffering", { reason }); } @@ -436,38 +432,25 @@ export function createStretcherEngine( } function getPositionInOriginalBuffer(): number { - if (phase === "ended") return totalDuration; - if (phase === "waiting") return offset; - if (phase === "buffering" && bufferingResumePosition !== null) { - return bufferingResumePosition; - } - - const chunk = chunks[currentChunkIndex]; - if (!chunk) return 0; - - // Nominal start time of this chunk in the original buffer - const nominalStartSample = chunk.inputStartSample + chunk.overlapBefore; - const nominalStartSec = nominalStartSample / sampleRate; - - // Position within the current chunk (in output time) - const posInChunk = chunkPlayer.getCurrentPosition(); - - // Subtract the crossfade overlap kept at the start of non-first chunks - const crossfadeOffset = chunk.overlapBefore > 0 ? CROSSFADE_SEC : 0; - const adjustedPosInChunk = Math.max(0, posInChunk - crossfadeOffset); - - // Convert output position back to original buffer time - // output duration = input duration / tempo - const posInOriginal = adjustedPosInChunk * currentTempo; - - return Math.min(nominalStartSec + posInOriginal, totalDuration); + const chunk = chunks[currentChunkIndex] ?? null; + return calcPositionInOriginalBuffer({ + phase, + totalDuration, + offset, + bufferingResumePosition, + currentTempo, + sampleRate, + crossfadeSec: CROSSFADE_SEC, + chunk: chunk + ? { inputStartSample: chunk.inputStartSample, overlapBefore: chunk.overlapBefore } + : null, + posInChunk: chunkPlayer.getCurrentPosition(), + }); } function getStatus(): StretcherStatus { const readyCount = chunks.filter((c) => c.state === "ready").length; - const convertingCount = chunks.filter( - (c) => c.state === "converting", - ).length; + const convertingCount = chunks.filter((c) => c.state === "converting").length; const total = chunks.length; return { @@ -493,9 +476,7 @@ export function createStretcherEngine( function getSnapshot(): StretcherSnapshotExtension { const readyCount = chunks.filter((c) => c.state === "ready").length; const total = chunks.length; - const convertingCount = chunks.filter( - (c) => c.state === "converting", - ).length; + const convertingCount = chunks.filter((c) => c.state === "converting").length; const windowStart = Math.max(0, currentChunkIndex - keepBehind); const windowEnd = Math.min(total - 1, currentChunkIndex + keepAhead); @@ -542,12 +523,39 @@ export function createStretcherEngine( function resume(): void { if (disposed || phase !== "paused") return; + if (pendingTempoChange) { + pendingTempoChange = false; + currentChunkIndex = getChunkIndexForTime(chunks, bufferingResumePosition!, sampleRate); + enterBuffering("tempo-change"); + scheduler.updatePriorities(currentChunkIndex); + scheduler.handleTempoChange(currentTempo); + return; + } + + if (bufferingResumePosition !== null) { + const resumePos = bufferingResumePosition; + bufferingResumePosition = null; + const chunk = chunks[currentChunkIndex]; + if (chunk && chunk.state === "ready" && chunk.outputBuffer) { + const nominalStartSample = chunk.inputStartSample + chunk.overlapBefore; + const nominalStartSec = nominalStartSample / sampleRate; + const offsetInOriginal = resumePos - nominalStartSec; + const offsetInOutput = Math.max(0, offsetInOriginal / currentTempo); + phase = "playing"; + playCurrentChunk(getCrossfadeStart(chunk) + offsetInOutput, true); + } else { + bufferingResumePosition = resumePos; + enterBuffering("seek"); + } + return; + } + const chunk = chunks[currentChunkIndex]; if (chunk && chunk.state === "ready") { const resumePosition = chunkPlayer.getCurrentPosition(); phase = "playing"; chunkPlayer.resume(); - playCurrentChunk(resumePosition); + playCurrentChunk(resumePosition, true); } else { enterBuffering("underrun"); } @@ -559,14 +567,14 @@ export function createStretcherEngine( const clamped = Math.max(0, Math.min(position, totalDuration)); const newChunkIdx = getChunkIndexForTime(chunks, clamped, sampleRate); currentChunkIndex = newChunkIdx; + nextChunkScheduledIndex = null; scheduler.handleSeek(newChunkIdx); const chunk = chunks[newChunkIdx]; if (chunk && chunk.state === "ready") { // Calculate offset within the chunk - const nominalStartSample = - chunk.inputStartSample + chunk.overlapBefore; + const nominalStartSample = chunk.inputStartSample + chunk.overlapBefore; const nominalStartSec = nominalStartSample / sampleRate; const offsetInOriginal = clamped - nominalStartSec; const offsetInOutput = offsetInOriginal / currentTempo; @@ -580,10 +588,16 @@ export function createStretcherEngine( const clampedOffset = Math.min(Math.max(0, bufferOffset), audioBuf.duration - 0.001); chunkPlayer.handleSeek(audioBuf, clampedOffset); } + } else if (phase === "paused") { + bufferingResumePosition = clamped; } } else { - bufferingResumePosition = clamped; - enterBuffering("seek"); + if (phase === "paused") { + bufferingResumePosition = clamped; + } else { + bufferingResumePosition = clamped; + enterBuffering("seek"); + } } } @@ -597,19 +611,46 @@ export function createStretcherEngine( isLooping = value; } + let tempoDebounceTimer: ReturnType | null = null; + function setTempo(newTempo: number): void { if (disposed || phase === "ended" || newTempo === currentTempo) return; - bufferingResumePosition = getPositionInOriginalBuffer(); - currentChunkIndex = getChunkIndexForTime(chunks, bufferingResumePosition, sampleRate); + + if (phase === "paused") { + if (!pendingTempoChange) { + bufferingResumePosition = getPositionInOriginalBuffer(); + } + currentTempo = newTempo; + pendingTempoChange = true; + return; + } + + const isFirstInBurst = tempoDebounceTimer === null; + + if (isFirstInBurst) { + bufferingResumePosition = getPositionInOriginalBuffer(); + currentChunkIndex = getChunkIndexForTime(chunks, bufferingResumePosition, sampleRate); + enterBuffering("tempo-change"); + } + currentTempo = newTempo; - enterBuffering("tempo-change"); - scheduler.updatePriorities(currentChunkIndex); - scheduler.handleTempoChange(newTempo); + + if (tempoDebounceTimer !== null) clearTimeout(tempoDebounceTimer); + tempoDebounceTimer = setTimeout(() => { + tempoDebounceTimer = null; + if (disposed || phase === "ended") return; + scheduler.updatePriorities(currentChunkIndex); + scheduler.handleTempoChange(currentTempo); + }, 50); } function dispose(): void { if (disposed) return; disposed = true; + if (tempoDebounceTimer !== null) { + clearTimeout(tempoDebounceTimer); + tempoDebounceTimer = null; + } chunkPlayer.dispose(); scheduler.dispose(); workerManager.terminate(); diff --git a/src/stretcher/index.ts b/src/stretcher/index.ts index 396398d..ca77dbf 100644 --- a/src/stretcher/index.ts +++ b/src/stretcher/index.ts @@ -5,21 +5,21 @@ export { createStretcherEngine } from "./engine.js"; export type { - // Core types - ChunkState, - StretcherPlaybackState, BufferHealth, + BufferMonitor, ChunkInfo, + ChunkPlayer, + // Core types + ChunkState, + ConversionEstimator, + ConversionScheduler, + PriorityQueue, StretcherEngine, StretcherEngineOptions, - StretcherStatus, - StretcherSnapshotExtension, StretcherEvents, + StretcherPlaybackState, + StretcherSnapshotExtension, + StretcherStatus, // Component interfaces WorkerManager, - PriorityQueue, - ConversionScheduler, - ChunkPlayer, - BufferMonitor, - ConversionEstimator, } from "./types.js"; diff --git a/src/stretcher/main-thread-processor.ts b/src/stretcher/main-thread-processor.ts index 06b97af..1146027 100644 --- a/src/stretcher/main-thread-processor.ts +++ b/src/stretcher/main-thread-processor.ts @@ -3,8 +3,8 @@ // Worker support, e.g. iOS Safari 14-, strict CSP) // --------------------------------------------------------------------------- -import { wsolaTimeStretch } from "./wsola.js"; import type { WorkerManager, WorkerResponse } from "./types.js"; +import { wsolaTimeStretch } from "./wsola.js"; /** * Create a WorkerManager-compatible processor that runs WSOLA on the main @@ -19,7 +19,7 @@ export function createMainThreadProcessor( // Track the current conversion so it can be cancelled let currentChunkIndex: number | null = null; - let cancelledChunks = new Set(); + const cancelledChunks = new Set(); let busy = false; return { diff --git a/src/stretcher/position-calc.ts b/src/stretcher/position-calc.ts new file mode 100644 index 0000000..b17eb77 --- /dev/null +++ b/src/stretcher/position-calc.ts @@ -0,0 +1,49 @@ +// --------------------------------------------------------------------------- +// Pure function for calculating position in the original audio buffer +// --------------------------------------------------------------------------- + +import type { StretcherPlaybackState } from "./types.js"; + +export interface PositionCalcParams { + phase: StretcherPlaybackState; + totalDuration: number; + offset: number; + bufferingResumePosition: number | null; + currentTempo: number; + sampleRate: number; + crossfadeSec: number; + chunk: { + inputStartSample: number; + overlapBefore: number; + } | null; + posInChunk: number; +} + +/** + * Calculate the current position in the original buffer from the stretcher + * engine's internal state. + * + * Pure function — no side effects. + */ +export function calcPositionInOriginalBuffer(p: PositionCalcParams): number { + if (p.phase === "ended") return p.totalDuration; + if (p.phase === "waiting") return p.offset; + if ((p.phase === "buffering" || p.phase === "paused") && p.bufferingResumePosition !== null) { + return p.bufferingResumePosition; + } + + if (!p.chunk) return 0; + + // Nominal start time of this chunk in the original buffer + const nominalStartSample = p.chunk.inputStartSample + p.chunk.overlapBefore; + const nominalStartSec = nominalStartSample / p.sampleRate; + + // Subtract the crossfade overlap kept at the start of non-first chunks + const crossfadeOffset = p.chunk.overlapBefore > 0 ? p.crossfadeSec : 0; + const adjustedPosInChunk = Math.max(0, p.posInChunk - crossfadeOffset); + + // Convert output position back to original buffer time + const posInOriginal = adjustedPosInChunk * p.currentTempo; + + return Math.min(nominalStartSec + posInOriginal, p.totalDuration); +} diff --git a/src/stretcher/priority-queue.ts b/src/stretcher/priority-queue.ts index 9608120..80a7d57 100644 --- a/src/stretcher/priority-queue.ts +++ b/src/stretcher/priority-queue.ts @@ -9,9 +9,7 @@ import type { PriorityQueue } from "./types.js"; * * @param compareFn - Returns negative if a has higher priority (lower value) than b */ -export function createPriorityQueue( - compareFn: (a: T, b: T) => number, -): PriorityQueue { +export function createPriorityQueue(compareFn: (a: T, b: T) => number): PriorityQueue { const heap: T[] = []; function parent(i: number): number { diff --git a/src/stretcher/transition-timing.ts b/src/stretcher/transition-timing.ts new file mode 100644 index 0000000..752b46f --- /dev/null +++ b/src/stretcher/transition-timing.ts @@ -0,0 +1,20 @@ +// --------------------------------------------------------------------------- +// Pure function for calculating gapless transition delay +// --------------------------------------------------------------------------- + +/** Safety margin added to the transition timer to avoid premature cleanup (ms). */ +export const TRANSITION_MARGIN_MS = 50; + +/** + * Calculate the delay (in ms) before executing a chunk transition callback. + * + * The delay ensures the next chunk has started playing before we clean up the + * previous source node. + * + * @param startTime - The AudioContext time at which the next chunk starts playing + * @param currentTime - The current AudioContext time + * @returns Delay in milliseconds (always >= 0) + */ +export function calcTransitionDelay(startTime: number, currentTime: number): number { + return Math.max(0, (startTime - currentTime) * 1000 + TRANSITION_MARGIN_MS); +} diff --git a/src/stretcher/types.ts b/src/stretcher/types.ts index 7c7fa3a..6928a96 100644 --- a/src/stretcher/types.ts +++ b/src/stretcher/types.ts @@ -13,12 +13,7 @@ export type ChunkState = | "evicted"; /** Playback state of the stretcher engine. */ -export type StretcherPlaybackState = - | "waiting" - | "playing" - | "buffering" - | "paused" - | "ended"; +export type StretcherPlaybackState = "waiting" | "playing" | "buffering" | "paused" | "ended"; /** Buffer health classification. */ export type BufferHealth = "healthy" | "low" | "critical" | "empty"; @@ -78,10 +73,7 @@ export interface WorkerErrorResponse { error: string; } -export type WorkerResponse = - | WorkerResultResponse - | WorkerCancelledResponse - | WorkerErrorResponse; +export type WorkerResponse = WorkerResultResponse | WorkerCancelledResponse | WorkerErrorResponse; // --------------------------------------------------------------------------- // Conversion Scheduler @@ -175,9 +167,9 @@ export interface StretcherEvents { buffering: { reason: "initial" | "seek" | "tempo-change" | "underrun" }; buffered: { stallDuration: number }; chunkready: { index: number }; - complete: void; - ended: void; - loop: void; + complete: undefined; + ended: undefined; + loop: undefined; error: { message: string; chunkIndex?: number; fatal: boolean }; } @@ -197,10 +189,7 @@ export interface StretcherEngine { event: K, handler: (data: StretcherEvents[K]) => void, ): () => void; - off( - event: K, - handler: (data: StretcherEvents[K]) => void, - ): void; + off(event: K, handler: (data: StretcherEvents[K]) => void): void; dispose(): void; } @@ -253,11 +242,7 @@ export interface ConversionScheduler { dispatchNext(): void; getChunks(): ChunkInfo[]; dispose(): void; - _handleResult( - chunkIndex: number, - outputData: Float32Array[], - outputLength: number, - ): void; + _handleResult(chunkIndex: number, outputData: Float32Array[], outputLength: number): void; _handleError(chunkIndex: number, error: string): void; } @@ -270,6 +255,7 @@ export interface ChunkPlayer { buffer: AudioBuffer, startTime: number, offsetInChunk?: number, + skipFadeIn?: boolean, ): void; scheduleNext(buffer: AudioBuffer, startTime: number): void; hasNextScheduled(): boolean; @@ -291,14 +277,8 @@ export interface ChunkPlayer { export interface BufferMonitor { getHealth(currentChunkIndex: number, chunks: ChunkInfo[]): BufferHealth; getAheadSeconds(currentChunkIndex: number, chunks: ChunkInfo[]): number; - shouldEnterBuffering( - currentChunkIndex: number, - chunks: ChunkInfo[], - ): boolean; - shouldExitBuffering( - currentChunkIndex: number, - chunks: ChunkInfo[], - ): boolean; + shouldEnterBuffering(currentChunkIndex: number, chunks: ChunkInfo[]): boolean; + shouldExitBuffering(currentChunkIndex: number, chunks: ChunkInfo[]): boolean; } // --------------------------------------------------------------------------- diff --git a/src/stretcher/worker-inline.ts b/src/stretcher/worker-inline.ts index 8f48633..3a12b79 100644 --- a/src/stretcher/worker-inline.ts +++ b/src/stretcher/worker-inline.ts @@ -5,11 +5,7 @@ // for execution inside a Web Worker. The logic mirrors wsola.ts. // --------------------------------------------------------------------------- -import { - WSOLA_FRAME_SIZE, - WSOLA_HOP_SIZE, - WSOLA_TOLERANCE, -} from "./constants.js"; +import { WSOLA_FRAME_SIZE, WSOLA_HOP_SIZE, WSOLA_TOLERANCE } from "./constants.js"; function getWorkerCode(): string { // Embed constants directly into the worker code @@ -66,6 +62,14 @@ function wsolaTimeStretch(channels, tempo, sampleRate) { return { output: channels.map(function() { return new Float32Array(0); }), length: 0 }; } + var TEMPO_IDENTITY_EPSILON = 0.001; + if (Math.abs(tempo - 1.0) < TEMPO_IDENTITY_EPSILON) { + return { + output: channels.map(function(ch) { return new Float32Array(ch); }), + length: inputLength + }; + } + var synthesisHop = HOP_SIZE; var analysisHop = Math.round(HOP_SIZE * tempo); var numFrames = Math.floor((inputLength - FRAME_SIZE) / analysisHop) + 1; @@ -133,7 +137,7 @@ function wsolaTimeStretch(channels, tempo, sampleRate) { if (outIdx >= estimatedOutputLength) break; var sample = input[inIdx]; output[outIdx] += sample * windowFunc[i]; - prevFrame[i] = sample; + prevFrame[i] = sample * windowFunc[i]; } } diff --git a/src/stretcher/worker-manager.ts b/src/stretcher/worker-manager.ts index ef2ad53..608eecd 100644 --- a/src/stretcher/worker-manager.ts +++ b/src/stretcher/worker-manager.ts @@ -3,8 +3,8 @@ // --------------------------------------------------------------------------- import { MAX_WORKER_CRASHES, WORKER_POOL_SIZE } from "./constants.js"; -import { createWorkerURL, revokeWorkerURL } from "./worker-inline.js"; import type { WorkerManager, WorkerResponse } from "./types.js"; +import { createWorkerURL, revokeWorkerURL } from "./worker-inline.js"; interface WorkerSlot { worker: Worker | null; @@ -25,6 +25,7 @@ export function createWorkerManager( ): WorkerManager { let workerURL: string | null = null; let terminated = false; + let allDeadFired = false; const postTimes = new Map(); const slots: WorkerSlot[] = []; @@ -59,11 +60,13 @@ export function createWorkerManager( if (response.type === "result" || response.type === "cancelled") { slot.busy = false; slot.currentChunkIndex = null; + postTimes.delete(response.chunkIndex); } if (response.type === "error") { slot.busy = false; slot.currentChunkIndex = null; + postTimes.delete(response.chunkIndex); onError(response); return; } @@ -80,6 +83,7 @@ export function createWorkerManager( slot.crashCount++; if (failedChunkIndex !== null) { + postTimes.delete(failedChunkIndex); onError({ type: "error", chunkIndex: failedChunkIndex, @@ -104,7 +108,8 @@ export function createWorkerManager( chunkIndex: failedChunkIndex ?? -1, error: `Worker crashed ${slot.crashCount} times, giving up`, }); - if (isAllDead()) { + if (!allDeadFired && isAllDead()) { + allDeadFired = true; onAllDead?.(); } } @@ -126,7 +131,8 @@ export function createWorkerManager( } // If all Workers failed to spawn, notify immediately - if (isAllDead()) { + if (!allDeadFired && isAllDead()) { + allDeadFired = true; onAllDead?.(); } @@ -184,7 +190,7 @@ export function createWorkerManager( cancelChunk(chunkIndex: number): void { if (terminated) return; const slot = findSlotByChunk(chunkIndex); - if (slot && slot.worker) { + if (slot?.worker) { slot.worker.postMessage({ type: "cancel", chunkIndex }); } }, diff --git a/src/stretcher/wsola.ts b/src/stretcher/wsola.ts index bcf8d51..530091f 100644 --- a/src/stretcher/wsola.ts +++ b/src/stretcher/wsola.ts @@ -97,6 +97,15 @@ export function wsolaTimeStretch( return { output: channels.map(() => new Float32Array(0)), length: 0 }; } + // At tempo≈1.0 (identity), skip WSOLA to avoid NCC search artifacts + const TEMPO_IDENTITY_EPSILON = 0.001; + if (Math.abs(tempo - 1.0) < TEMPO_IDENTITY_EPSILON) { + return { + output: channels.map((ch) => new Float32Array(ch)), + length: inputLength, + }; + } + // WSOLA: synthesisHop is fixed, analysisHop varies with tempo const synthesisHop = hopSize; const analysisHop = Math.round(hopSize * tempo); @@ -112,9 +121,7 @@ export function wsolaTimeStretch( } const estimatedOutputLength = (numFrames - 1) * synthesisHop + frameSize; - const outputChannels = channels.map( - () => new Float32Array(estimatedOutputLength), - ); + const outputChannels = channels.map(() => new Float32Array(estimatedOutputLength)); const windowFunc = createHannWindow(frameSize); // Normalization buffer for overlap-add @@ -149,16 +156,10 @@ export function wsolaTimeStretch( const overlapStart = frameSize - synthesisHop; const overlapSize = Math.min(synthesisHop, frameSize - overlapStart); - const refSlice = refChannel.subarray( - overlapStart, - overlapStart + overlapSize, - ); + const refSlice = refChannel.subarray(overlapStart, overlapStart + overlapSize); // Search buffer: extract the region to search in - const searchSlice = inputChannel.subarray( - searchStart, - searchEnd + overlapSize, - ); + const searchSlice = inputChannel.subarray(searchStart, searchEnd + overlapSize); const bestOffset = findBestOffset( refSlice, @@ -185,7 +186,7 @@ export function wsolaTimeStretch( const sample = input[inIdx]!; output[outIdx]! += sample * windowFunc[i]!; - prevFrame[i] = sample; + prevFrame[i] = sample * windowFunc[i]!; } } @@ -213,9 +214,7 @@ export function wsolaTimeStretch( } // Trim output to actual length - const trimmedOutput = outputChannels.map((ch) => - ch.subarray(0, actualOutputLength), - ); + const trimmedOutput = outputChannels.map((ch) => ch.subarray(0, actualOutputLength)); return { output: trimmedOutput, length: actualOutputLength }; } diff --git a/src/synth.ts b/src/synth.ts index 0039936..b4bc660 100644 --- a/src/synth.ts +++ b/src/synth.ts @@ -25,10 +25,7 @@ export function createSineBuffer( /** * Create an `AudioBuffer` containing white noise. */ -export function createNoiseBuffer( - ctx: AudioContext, - duration: number, -): AudioBuffer { +export function createNoiseBuffer(ctx: AudioContext, duration: number): AudioBuffer { const length = Math.ceil(ctx.sampleRate * duration); const buffer = ctx.createBuffer(1, length, ctx.sampleRate); const data = buffer.getChannelData(0); @@ -55,8 +52,7 @@ export function createClickBuffer( for (let i = 0; i < length; i++) { // Exponential decay envelope. const envelope = Math.exp((-5 * i) / length); - data[i] = - envelope * Math.sin((2 * Math.PI * frequency * i) / ctx.sampleRate); + data[i] = envelope * Math.sin((2 * Math.PI * frequency * i) / ctx.sampleRate); } return buffer; diff --git a/src/types.ts b/src/types.ts index 52fb628..56753f1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,13 +78,13 @@ export interface PlaybackSnapshot { /** Event map emitted by a Playback instance. */ export interface PlaybackEventMap { - play: void; - pause: void; - resume: void; + play: undefined; + pause: undefined; + resume: undefined; seek: { position: number }; - stop: void; - ended: void; - loop: void; + stop: undefined; + ended: undefined; + loop: undefined; statechange: { state: PlaybackState }; timeupdate: { position: number; duration: number }; buffering: { reason: "initial" | "seek" | "tempo-change" | "underrun" }; diff --git a/src/waveform.ts b/src/waveform.ts index 36f5081..9743500 100644 --- a/src/waveform.ts +++ b/src/waveform.ts @@ -4,17 +4,18 @@ import type { ExtractPeaksOptions, PeakPair } from "./types.js"; +function calcBlockSize(dataLength: number, resolution: number): number { + return Math.max(1, Math.floor(dataLength / resolution)); +} + /** * Extract normalised peak amplitude values from an `AudioBuffer`. * Returns an array of numbers in the range `[0, 1]`. */ -export function extractPeaks( - buffer: AudioBuffer, - options?: ExtractPeaksOptions, -): number[] { +export function extractPeaks(buffer: AudioBuffer, options?: ExtractPeaksOptions): number[] { const { resolution = 200, channel = 0 } = options ?? {}; const data = buffer.getChannelData(channel); - const blockSize = Math.floor(data.length / resolution); + const blockSize = calcBlockSize(data.length, resolution); const peaks: number[] = []; for (let i = 0; i < resolution; i++) { @@ -34,13 +35,10 @@ export function extractPeaks( /** * Extract min/max peak pairs for detailed waveform rendering. */ -export function extractPeakPairs( - buffer: AudioBuffer, - options?: ExtractPeaksOptions, -): PeakPair[] { +export function extractPeakPairs(buffer: AudioBuffer, options?: ExtractPeaksOptions): PeakPair[] { const { resolution = 200, channel = 0 } = options ?? {}; const data = buffer.getChannelData(channel); - const blockSize = Math.floor(data.length / resolution); + const blockSize = calcBlockSize(data.length, resolution); const pairs: PeakPair[] = []; for (let i = 0; i < resolution; i++) { @@ -72,6 +70,9 @@ export function extractRMS( const { resolution = 200, channel = 0 } = options ?? {}; if (channel === -1) { + if (buffer.numberOfChannels === 0) { + return []; + } // Average across all channels. const allChannels: number[][] = []; for (let ch = 0; ch < buffer.numberOfChannels; ch++) { @@ -87,18 +88,19 @@ export function extractRMS( } const data = buffer.getChannelData(channel); - const blockSize = Math.floor(data.length / resolution); + const blockSize = calcBlockSize(data.length, resolution); const rms: number[] = []; for (let i = 0; i < resolution; i++) { const start = i * blockSize; const end = Math.min(start + blockSize, data.length); let sumSq = 0; + const count = end - start; for (let j = start; j < end; j++) { const s = data[j]!; sumSq += s * s; } - rms.push(Math.sqrt(sumSq / (end - start))); + rms.push(count > 0 ? Math.sqrt(sumSq / count) : 0); } return rms; diff --git a/tests/adapters.test.ts b/tests/adapters.test.ts new file mode 100644 index 0000000..61fc7f9 --- /dev/null +++ b/tests/adapters.test.ts @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getSnapshot, + onFrame, + subscribeSnapshot, + whenEnded, + whenPosition, +} from "../src/adapters.js"; +import type { Playback, PlaybackSnapshot } from "../src/types.js"; +import { createMockPlayback } from "./helpers/audio-mocks.js"; + +describe("adapters", () => { + // ------------------------------------------------------------------------- + // getSnapshot + // ------------------------------------------------------------------------- + describe("getSnapshot", () => { + it("returns a snapshot with state, position, duration, progress", () => { + const pb = createMockPlayback({ + state: "playing", + currentTime: 5, + duration: 10, + progress: 0.5, + }); + const snap = getSnapshot(pb as unknown as Playback); + expect(snap.state).toBe("playing"); + expect(snap.position).toBe(5); + expect(snap.duration).toBe(10); + expect(snap.progress).toBe(0.5); + }); + + it("caches the snapshot (returns same reference on second call)", () => { + const pb = createMockPlayback(); + const snap1 = getSnapshot(pb as unknown as Playback); + const snap2 = getSnapshot(pb as unknown as Playback); + expect(snap1).toBe(snap2); + }); + + it("does not include stretcher field when not available", () => { + const pb = createMockPlayback(); + const snap = getSnapshot(pb as unknown as Playback); + expect(snap.stretcher).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // subscribeSnapshot + // ------------------------------------------------------------------------- + describe("subscribeSnapshot", () => { + it("subscribes to 4 events (statechange, timeupdate, seek, ended)", () => { + const pb = createMockPlayback(); + const callback = vi.fn(); + subscribeSnapshot(pb as unknown as Playback, callback); + + expect(pb.on).toHaveBeenCalledTimes(4); + const events = pb.on.mock.calls.map((c: unknown[]) => c[0]); + expect(events).toContain("statechange"); + expect(events).toContain("timeupdate"); + expect(events).toContain("seek"); + expect(events).toContain("ended"); + }); + + it("calls callback when statechange fires", () => { + const pb = createMockPlayback(); + const callback = vi.fn(); + subscribeSnapshot(pb as unknown as Playback, callback); + + pb._emit("statechange", { state: "paused" }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("updates cache before calling callback (snapshot is fresh)", () => { + const pb = createMockPlayback({ + state: "playing", + currentTime: 0, + duration: 10, + progress: 0, + }); + let capturedSnap: PlaybackSnapshot | null = null; + + subscribeSnapshot(pb as unknown as Playback, () => { + capturedSnap = getSnapshot(pb as unknown as Playback); + }); + + // Change mock state + pb.getState.mockReturnValue("paused"); + pb.getCurrentTime.mockReturnValue(5); + pb.getProgress.mockReturnValue(0.5); + + pb._emit("statechange", { state: "paused" }); + + expect(capturedSnap).not.toBeNull(); + expect(capturedSnap!.state).toBe("paused"); + expect(capturedSnap!.position).toBe(5); + }); + + it("unsubscribe removes all event listeners", () => { + const pb = createMockPlayback(); + const callback = vi.fn(); + const unsub = subscribeSnapshot(pb as unknown as Playback, callback); + + unsub(); + + // After unsub, events should not trigger callback + pb._emit("statechange", { state: "paused" }); + expect(callback).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // onFrame + // ------------------------------------------------------------------------- + describe("onFrame", () => { + let rafCallbacks: Array<(time: number) => void>; + let rafIdCounter: number; + + beforeEach(() => { + rafCallbacks = []; + rafIdCounter = 0; + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((cb: (time: number) => void) => { + rafCallbacks.push(cb); + return ++rafIdCounter; + }), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("calls callback on each animation frame", () => { + const pb = createMockPlayback({ + state: "playing", + currentTime: 1, + duration: 10, + progress: 0.1, + }); + const callback = vi.fn(); + + onFrame(pb as unknown as Playback, callback); + + // First rAF should be registered + expect(rafCallbacks.length).toBe(1); + + // Simulate frame + rafCallbacks[0]!(0); + expect(callback).toHaveBeenCalledTimes(1); + const snap = callback.mock.calls[0]![0] as PlaybackSnapshot; + expect(snap.state).toBe("playing"); + expect(snap.position).toBe(1); + }); + + it("stop cancels the animation frame loop", () => { + const pb = createMockPlayback(); + const callback = vi.fn(); + + const stop = onFrame(pb as unknown as Playback, callback); + stop(); + + expect(cancelAnimationFrame).toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // whenEnded + // ------------------------------------------------------------------------- + describe("whenEnded", () => { + it("resolves when ended event fires", async () => { + const pb = createMockPlayback(); + const promise = whenEnded(pb as unknown as Playback); + + pb._emit("ended"); + + await expect(promise).resolves.toBeUndefined(); + }); + + it("unsubscribes after resolving", async () => { + const pb = createMockPlayback(); + const promise = whenEnded(pb as unknown as Playback); + + pb._emit("ended"); + await promise; + + // The 'ended' handler set should now be empty + pb._emit("ended"); // should not cause issues + }); + }); + + // ------------------------------------------------------------------------- + // whenPosition + // ------------------------------------------------------------------------- + describe("whenPosition", () => { + it("resolves when position is reached", async () => { + const pb = createMockPlayback(); + const promise = whenPosition(pb as unknown as Playback, 5); + + pb._emit("timeupdate", { position: 3, duration: 10 }); + // Should not have resolved yet + + pb._emit("timeupdate", { position: 5, duration: 10 }); + await expect(promise).resolves.toBeUndefined(); + }); + + it("resolves when position is exceeded", async () => { + const pb = createMockPlayback(); + const promise = whenPosition(pb as unknown as Playback, 5); + + pb._emit("timeupdate", { position: 7, duration: 10 }); + await expect(promise).resolves.toBeUndefined(); + }); + + it("resolves immediately if position is already passed (fixed: initial check)", async () => { + const pb = createMockPlayback({ currentTime: 10, duration: 10, progress: 1 }); + const promise = whenPosition(pb as unknown as Playback, 5); + await expect(promise).resolves.toBeUndefined(); + }); + + it("unsubscribes after resolving", async () => { + const pb = createMockPlayback(); + const promise = whenPosition(pb as unknown as Playback, 5); + + pb._emit("timeupdate", { position: 5, duration: 10 }); + await promise; + + // Subsequent events should not cause issues + pb._emit("timeupdate", { position: 6, duration: 10 }); + }); + }); +}); diff --git a/tests/browser/context.browser.test.ts b/tests/browser/context.browser.test.ts new file mode 100644 index 0000000..f0028f8 --- /dev/null +++ b/tests/browser/context.browser.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createContext, ensureRunning, now, resumeContext } from "../../src/context"; + +describe("context — browser", () => { + let ctx: AudioContext | null = null; + + afterEach(async () => { + if (ctx && ctx.state !== "closed") { + await ctx.close(); + } + ctx = null; + }); + + it("createContext returns a real AudioContext in running state", async () => { + ctx = createContext(); + expect(ctx).toBeInstanceOf(AudioContext); + // Chromium with --autoplay-policy=no-user-gesture-required starts running + await ensureRunning(ctx); + expect(ctx.state).toBe("running"); + }); + + it("createContext with sampleRate option sets the sample rate", async () => { + ctx = createContext({ sampleRate: 22050 }); + expect(ctx.sampleRate).toBe(22050); + }); + + it("now() returns a non-negative number that advances", async () => { + ctx = createContext(); + await ensureRunning(ctx); + + // Connect an oscillator to force audio processing to advance currentTime + const osc = ctx.createOscillator(); + osc.connect(ctx.destination); + osc.start(); + + const t0 = now(ctx); + expect(t0).toBeGreaterThanOrEqual(0); + + // Wait for audio processing to advance currentTime + await new Promise((r) => setTimeout(r, 100)); + const t1 = now(ctx); + expect(t1).toBeGreaterThan(t0); + + osc.stop(); + osc.disconnect(); + }); + + it("resumeContext resumes a suspended context", async () => { + ctx = createContext(); + await ensureRunning(ctx); + expect(ctx.state).toBe("running"); + // resumeContext on already running context is a no-op + await resumeContext(ctx); + expect(ctx.state).toBe("running"); + }); + + it("close sets state to closed", async () => { + ctx = createContext(); + await ensureRunning(ctx); + await ctx.close(); + expect(ctx.state).toBe("closed"); + ctx = null; + }); +}); diff --git a/tests/browser/fade.browser.test.ts b/tests/browser/fade.browser.test.ts new file mode 100644 index 0000000..a1d21fc --- /dev/null +++ b/tests/browser/fade.browser.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { crossfade, fadeIn, fadeOut } from "../../src/fade"; + +describe("fade — browser", () => { + let ctx: AudioContext; + + afterEach(async () => { + if (ctx?.state !== "closed") { + await ctx.close(); + } + }); + + it("fadeIn ramps gain from 0 to target (linear)", async () => { + ctx = new AudioContext(); + await ctx.resume(); + + const gain = ctx.createGain(); + gain.gain.value = 0; + gain.connect(ctx.destination); + + fadeIn(gain, 1.0, { duration: 0.1, curve: "linear" }); + + // Wait for fade to complete + await new Promise((r) => setTimeout(r, 150)); + + expect(gain.gain.value).toBeCloseTo(1.0, 1); + }); + + it("fadeOut ramps gain to 0 (linear)", async () => { + ctx = new AudioContext(); + await ctx.resume(); + + const gain = ctx.createGain(); + gain.gain.value = 1.0; + gain.connect(ctx.destination); + + fadeOut(gain, { duration: 0.1, curve: "linear" }); + + // Wait for fade to complete + await new Promise((r) => setTimeout(r, 150)); + + expect(gain.gain.value).toBeCloseTo(0.0, 1); + }); + + it("fadeIn with exponential curve reaches target", async () => { + ctx = new AudioContext(); + await ctx.resume(); + + const gain = ctx.createGain(); + gain.gain.value = 0; + gain.connect(ctx.destination); + + fadeIn(gain, 0.8, { duration: 0.1, curve: "exponential" }); + + await new Promise((r) => setTimeout(r, 150)); + + // Exponential ramp clamps to EXP_MIN, not exactly target + expect(gain.gain.value).toBeGreaterThan(0.5); + }); + + it("crossfade transitions between two GainNodes", async () => { + ctx = new AudioContext(); + await ctx.resume(); + + const gainA = ctx.createGain(); + const gainB = ctx.createGain(); + gainA.gain.value = 1.0; + gainB.gain.value = 0.0; + gainA.connect(ctx.destination); + gainB.connect(ctx.destination); + + crossfade(gainA, gainB, { duration: 0.1, curve: "linear" }); + + await new Promise((r) => setTimeout(r, 150)); + + expect(gainA.gain.value).toBeCloseTo(0.0, 1); + expect(gainB.gain.value).toBeCloseTo(1.0, 1); + }); + + it("fadeIn with equal-power curve reaches target", async () => { + ctx = new AudioContext(); + await ctx.resume(); + + const gain = ctx.createGain(); + gain.gain.value = 0; + gain.connect(ctx.destination); + + fadeIn(gain, 1.0, { duration: 0.1, curve: "equal-power" }); + + await new Promise((r) => setTimeout(r, 150)); + + expect(gain.gain.value).toBeCloseTo(1.0, 1); + }); +}); diff --git a/tests/browser/nodes.browser.test.ts b/tests/browser/nodes.browser.test.ts new file mode 100644 index 0000000..a1e9fb8 --- /dev/null +++ b/tests/browser/nodes.browser.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + chain, + createAnalyser, + createCompressor, + createFilter, + createGain, + createPanner, + disconnectChain, + rampGain, +} from "../../src/nodes"; +import { createSineBuffer } from "../../src/synth"; + +describe("nodes — browser", () => { + let ctx: AudioContext; + + afterEach(async () => { + if (ctx?.state !== "closed") { + await ctx.close(); + } + }); + + it("createGain creates a real GainNode with correct initial value", () => { + ctx = new AudioContext(); + const gain = createGain(ctx, 0.5); + expect(gain).toBeInstanceOf(GainNode); + expect(gain.gain.value).toBeCloseTo(0.5, 2); + }); + + it("createAnalyser creates a real AnalyserNode", () => { + ctx = new AudioContext(); + const analyser = createAnalyser(ctx, { fftSize: 512 }); + expect(analyser).toBeInstanceOf(AnalyserNode); + expect(analyser.fftSize).toBe(512); + }); + + it("createFilter creates a BiquadFilterNode", () => { + ctx = new AudioContext(); + const filter = createFilter(ctx, { type: "highpass", frequency: 1000 }); + expect(filter).toBeInstanceOf(BiquadFilterNode); + expect(filter.type).toBe("highpass"); + expect(filter.frequency.value).toBeCloseTo(1000, 0); + }); + + it("createPanner creates a StereoPannerNode", () => { + ctx = new AudioContext(); + const panner = createPanner(ctx, -0.5); + expect(panner).toBeInstanceOf(StereoPannerNode); + expect(panner.pan.value).toBeCloseTo(-0.5, 2); + }); + + it("createCompressor creates a DynamicsCompressorNode", () => { + ctx = new AudioContext(); + const comp = createCompressor(ctx, { threshold: -20, ratio: 4 }); + expect(comp).toBeInstanceOf(DynamicsCompressorNode); + expect(comp.threshold.value).toBeCloseTo(-20, 0); + expect(comp.ratio.value).toBeCloseTo(4, 0); + }); + + it("chain connects nodes and signal passes through to AnalyserNode", async () => { + ctx = new AudioContext(); + await ctx.resume(); + + const buffer = createSineBuffer(ctx, 440, 0.2); + const source = ctx.createBufferSource(); + source.buffer = buffer; + + const gain = createGain(ctx, 1); + const analyser = createAnalyser(ctx, { fftSize: 256 }); + + chain(source, gain, analyser, ctx.destination); + source.start(); + + // Wait for some audio to process + await new Promise((r) => setTimeout(r, 100)); + + const data = new Float32Array(analyser.frequencyBinCount); + analyser.getFloatFrequencyData(data); + + // Should have some non-negative-infinity frequency data (signal present) + const maxValue = Math.max(...data); + expect(maxValue).toBeGreaterThan(-Infinity); + + disconnectChain(source, gain, analyser); + }); + + it("rampGain changes gain value over time", async () => { + ctx = new AudioContext(); + await ctx.resume(); + + const gain = createGain(ctx, 1.0); + gain.connect(ctx.destination); + + rampGain(gain, 0.0, 0.1); + + // Wait for ramp to complete + await new Promise((r) => setTimeout(r, 150)); + + expect(gain.gain.value).toBeCloseTo(0.0, 1); + }); +}); diff --git a/tests/browser/play.browser.test.ts b/tests/browser/play.browser.test.ts new file mode 100644 index 0000000..e787251 --- /dev/null +++ b/tests/browser/play.browser.test.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { play } from "../../src/play"; +import { createSineBuffer } from "../../src/synth"; + +describe("play() — browser", () => { + let ctx: AudioContext; + + afterEach(async () => { + if (ctx?.state !== "closed") { + await ctx.close(); + } + }); + + it("onended fires after buffer finishes (preservePitch: false)", async () => { + ctx = new AudioContext(); + await ctx.resume(); + const buffer = createSineBuffer(ctx, 440, 0.15); + + const pb = play(ctx, buffer, { preservePitch: false }); + expect(pb.getState()).toBe("playing"); + + await new Promise((resolve) => { + pb.on("ended", () => resolve()); + }); + expect(pb.getState()).toBe("stopped"); + }); + + it("pause and resume work correctly (preservePitch: false)", async () => { + ctx = new AudioContext(); + await ctx.resume(); + const buffer = createSineBuffer(ctx, 440, 1.0); + + const pb = play(ctx, buffer, { preservePitch: false }); + expect(pb.getState()).toBe("playing"); + + pb.pause(); + expect(pb.getState()).toBe("paused"); + + pb.resume(); + expect(pb.getState()).toBe("playing"); + + pb.stop(); + expect(pb.getState()).toBe("stopped"); + }); + + it("stop immediately stops playback (preservePitch: false)", async () => { + ctx = new AudioContext(); + await ctx.resume(); + const buffer = createSineBuffer(ctx, 440, 1.0); + + const pb = play(ctx, buffer, { preservePitch: false }); + pb.stop(); + expect(pb.getState()).toBe("stopped"); + }); + + it("seek changes position (preservePitch: false)", async () => { + ctx = new AudioContext(); + await ctx.resume(); + const buffer = createSineBuffer(ctx, 440, 1.0); + + const pb = play(ctx, buffer, { preservePitch: false }); + + pb.seek(0.5); + const pos = pb.getCurrentTime(); + // Position should be approximately 0.5 + expect(pos).toBeGreaterThanOrEqual(0.4); + expect(pos).toBeLessThanOrEqual(0.7); + + pb.stop(); + }); + + it("getDuration returns correct buffer duration (preservePitch: false)", async () => { + ctx = new AudioContext(); + await ctx.resume(); + const buffer = createSineBuffer(ctx, 440, 0.5); + + const pb = play(ctx, buffer, { preservePitch: false }); + expect(pb.getDuration()).toBeCloseTo(0.5, 1); + pb.stop(); + }); + + it("loop keeps playback playing beyond buffer duration (preservePitch: false)", async () => { + ctx = new AudioContext(); + await ctx.resume(); + const buffer = createSineBuffer(ctx, 440, 0.1); + + const pb = play(ctx, buffer, { loop: true, preservePitch: false }); + + // Wait longer than the buffer duration — without loop, it would have ended + await new Promise((r) => setTimeout(r, 300)); + + // With loop, playback should still be playing + expect(pb.getState()).toBe("playing"); + pb.stop(); + }); + + it("setPlaybackRate changes playback speed (preservePitch: false)", async () => { + ctx = new AudioContext(); + await ctx.resume(); + const buffer = createSineBuffer(ctx, 440, 0.5); + + const pb = play(ctx, buffer, { preservePitch: false }); + pb.setPlaybackRate(2.0); + + // At 2x speed, 0.5s buffer should finish in ~0.25s + await new Promise((resolve) => { + pb.on("ended", () => resolve()); + }); + expect(pb.getState()).toBe("stopped"); + }); + + it("timeupdate fires with position and duration (preservePitch: false)", async () => { + ctx = new AudioContext(); + await ctx.resume(); + const buffer = createSineBuffer(ctx, 440, 0.5); + + const pb = play(ctx, buffer, { preservePitch: false, timeupdateInterval: 20 }); + + const update = await new Promise<{ position: number; duration: number }>((resolve) => { + pb.on("timeupdate", (data) => resolve(data)); + }); + + expect(update.position).toBeGreaterThanOrEqual(0); + expect(update.duration).toBeCloseTo(0.5, 1); + + pb.stop(); + }); + + it("through option routes audio through node chain (preservePitch: false)", async () => { + ctx = new AudioContext(); + await ctx.resume(); + const buffer = createSineBuffer(ctx, 440, 0.2); + + const gain = ctx.createGain(); + gain.gain.value = 0.5; + const analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + + const pb = play(ctx, buffer, { + through: [gain, analyser], + preservePitch: false, + }); + + await new Promise((r) => setTimeout(r, 100)); + + const data = new Float32Array(analyser.frequencyBinCount); + analyser.getFloatFrequencyData(data); + const maxValue = Math.max(...data); + expect(maxValue).toBeGreaterThan(-Infinity); + + pb.stop(); + }); +}); diff --git a/tests/browser/stretcher.browser.test.ts b/tests/browser/stretcher.browser.test.ts new file mode 100644 index 0000000..3171a59 --- /dev/null +++ b/tests/browser/stretcher.browser.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { play } from "../../src/play"; +import { createSineBuffer } from "../../src/synth"; + +describe("stretcher (preservePitch) — browser", () => { + let ctx: AudioContext; + + afterEach(async () => { + if (ctx?.state !== "closed") { + await ctx.close(); + } + }); + + it("play with preservePitch: true starts and emits statechange", async () => { + ctx = new AudioContext(); + await ctx.resume(); + const buffer = createSineBuffer(ctx, 440, 1.0); + + const pb = play(ctx, buffer, { preservePitch: true }); + expect(pb.getState()).toBe("playing"); + expect(pb.getDuration()).toBeCloseTo(1.0, 1); + + // Wait for engine initialization + await new Promise((r) => setTimeout(r, 200)); + + // Should still be playing + expect(pb.getState()).toBe("playing"); + pb.stop(); + expect(pb.getState()).toBe("stopped"); + }); + + it("pause and resume work with stretcher", async () => { + ctx = new AudioContext(); + await ctx.resume(); + const buffer = createSineBuffer(ctx, 440, 2.0); + + const pb = play(ctx, buffer, { preservePitch: true }); + + // Wait for engine init + await new Promise((r) => setTimeout(r, 300)); + + pb.pause(); + expect(pb.getState()).toBe("paused"); + + const posAtPause = pb.getCurrentTime(); + + pb.resume(); + expect(pb.getState()).toBe("playing"); + + // Position should not have jumped significantly + const posAfterResume = pb.getCurrentTime(); + expect(Math.abs(posAfterResume - posAtPause)).toBeLessThan(0.5); + + pb.stop(); + }); + + it("setPlaybackRate (tempo change) works", async () => { + ctx = new AudioContext(); + await ctx.resume(); + const buffer = createSineBuffer(ctx, 440, 2.0); + + const pb = play(ctx, buffer, { preservePitch: true, playbackRate: 1.0 }); + + // Wait for engine init + await new Promise((r) => setTimeout(r, 300)); + + // Change tempo to 2x + pb.setPlaybackRate(2.0); + + // Wait for re-buffering + await new Promise((r) => setTimeout(r, 500)); + + // Should still be playing (or stopped if short enough) + const state = pb.getState(); + expect(["playing", "stopped"]).toContain(state); + + if (state === "playing") { + pb.stop(); + } + }); + + it("seek to position works", async () => { + ctx = new AudioContext(); + await ctx.resume(); + const buffer = createSineBuffer(ctx, 440, 2.0); + + const pb = play(ctx, buffer, { preservePitch: true }); + + // Wait for engine init + await new Promise((r) => setTimeout(r, 300)); + + pb.seek(1.0); + + // Wait for seek to process + await new Promise((r) => setTimeout(r, 200)); + + // Position should be near 1.0 + const pos = pb.getCurrentTime(); + expect(pos).toBeGreaterThanOrEqual(0.5); + + pb.stop(); + }); + + it("dispose cleans up resources", async () => { + ctx = new AudioContext(); + await ctx.resume(); + const buffer = createSineBuffer(ctx, 440, 1.0); + + const pb = play(ctx, buffer, { preservePitch: true }); + + // Wait for engine init + await new Promise((r) => setTimeout(r, 200)); + + pb.dispose(); + // After dispose, operations should be safe (no-ops) + pb.pause(); + pb.resume(); + pb.seek(0.5); + pb.stop(); + }); + + it("short buffer plays to end and emits ended", async () => { + ctx = new AudioContext(); + await ctx.resume(); + const buffer = createSineBuffer(ctx, 440, 0.3); + + const pb = play(ctx, buffer, { preservePitch: true }); + + const ended = await Promise.race([ + new Promise((resolve) => { + pb.on("ended", () => resolve(true)); + }), + new Promise((resolve) => setTimeout(() => resolve(false), 5000)), + ]); + + expect(ended).toBe(true); + expect(pb.getState()).toBe("stopped"); + }, 10000); +}); diff --git a/tests/buffer.test.ts b/tests/buffer.test.ts new file mode 100644 index 0000000..581dadc --- /dev/null +++ b/tests/buffer.test.ts @@ -0,0 +1,184 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getBufferInfo, loadBuffer, loadBufferFromBlob, loadBuffers } from "../src/buffer.js"; +import { createMockAudioBuffer, createMockAudioContext } from "./helpers/audio-mocks.js"; + +describe("buffer", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ------------------------------------------------------------------------- + // loadBuffer + // ------------------------------------------------------------------------- + describe("loadBuffer", () => { + it("fetches and decodes audio data", async () => { + const mockArrayBuffer = new ArrayBuffer(1024); + const mockBuffer = createMockAudioBuffer(1); + + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + headers: new Headers(), + arrayBuffer: async () => mockArrayBuffer, + })), + ); + + const ctx = createMockAudioContext(); + ctx.decodeAudioData = vi.fn(async () => mockBuffer) as unknown as typeof ctx.decodeAudioData; + + const result = await loadBuffer(ctx, "/test.mp3"); + expect(result).toBe(mockBuffer); + expect(fetch).toHaveBeenCalledWith("/test.mp3"); + }); + + it("throws on non-ok response", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: false, + status: 404, + statusText: "Not Found", + })), + ); + + const ctx = createMockAudioContext(); + await expect(loadBuffer(ctx, "/missing.mp3")).rejects.toThrow( + "Failed to fetch audio: 404 Not Found", + ); + }); + + it("reports progress when content-length is available", async () => { + const chunk1 = new Uint8Array([1, 2, 3, 4]); + const chunk2 = new Uint8Array([5, 6, 7, 8]); + let readCount = 0; + + const mockReader = { + read: vi.fn(async () => { + readCount++; + if (readCount === 1) return { done: false, value: chunk1 }; + if (readCount === 2) return { done: false, value: chunk2 }; + return { done: true, value: undefined }; + }), + }; + + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + headers: { get: (name: string) => (name === "content-length" ? "8" : null) }, + body: { getReader: () => mockReader }, + })), + ); + + const ctx = createMockAudioContext(); + const mockBuffer = createMockAudioBuffer(1); + ctx.decodeAudioData = vi.fn(async () => mockBuffer) as unknown as typeof ctx.decodeAudioData; + + const progressValues: number[] = []; + await loadBuffer(ctx, "/track.mp3", { + onProgress: (p) => progressValues.push(p), + }); + + expect(progressValues.length).toBe(2); + expect(progressValues[0]).toBeCloseTo(0.5); + expect(progressValues[1]).toBeCloseTo(1.0); + }); + + it("skips streaming when no content-length", async () => { + const mockArrayBuffer = new ArrayBuffer(1024); + const mockBuffer = createMockAudioBuffer(1); + + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + headers: { get: () => null }, + body: {}, + arrayBuffer: async () => mockArrayBuffer, + })), + ); + + const ctx = createMockAudioContext(); + ctx.decodeAudioData = vi.fn(async () => mockBuffer) as unknown as typeof ctx.decodeAudioData; + + const progressValues: number[] = []; + const result = await loadBuffer(ctx, "/track.mp3", { + onProgress: (p) => progressValues.push(p), + }); + + expect(result).toBe(mockBuffer); + expect(progressValues.length).toBe(0); + }); + }); + + // ------------------------------------------------------------------------- + // loadBufferFromBlob + // ------------------------------------------------------------------------- + describe("loadBufferFromBlob", () => { + it("decodes blob to AudioBuffer", async () => { + const mockArrayBuffer = new ArrayBuffer(512); + const mockBlob = { + arrayBuffer: vi.fn(async () => mockArrayBuffer), + } as unknown as Blob; + + const ctx = createMockAudioContext(); + const mockBuffer = createMockAudioBuffer(1); + ctx.decodeAudioData = vi.fn(async () => mockBuffer) as unknown as typeof ctx.decodeAudioData; + + const result = await loadBufferFromBlob(ctx, mockBlob); + expect(result).toBe(mockBuffer); + expect(ctx.decodeAudioData).toHaveBeenCalledWith(mockArrayBuffer); + }); + }); + + // ------------------------------------------------------------------------- + // loadBuffers + // ------------------------------------------------------------------------- + describe("loadBuffers", () => { + it("loads multiple buffers in parallel and returns Map", async () => { + const mockBuffer1 = createMockAudioBuffer(1); + const mockBuffer2 = createMockAudioBuffer(2); + let callCount = 0; + + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + headers: new Headers(), + arrayBuffer: async () => new ArrayBuffer(1024), + })), + ); + + const ctx = createMockAudioContext(); + ctx.decodeAudioData = vi.fn(async () => { + callCount++; + return callCount === 1 ? mockBuffer1 : mockBuffer2; + }) as unknown as typeof ctx.decodeAudioData; + + const result = await loadBuffers(ctx, { + kick: "/kick.wav", + snare: "/snare.wav", + }); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(2); + expect(result.has("kick")).toBe(true); + expect(result.has("snare")).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // getBufferInfo + // ------------------------------------------------------------------------- + describe("getBufferInfo", () => { + it("returns all metadata fields", () => { + const buf = createMockAudioBuffer(2.5, 48000, 2); + const info = getBufferInfo(buf); + expect(info.duration).toBe(2.5); + expect(info.numberOfChannels).toBe(2); + expect(info.sampleRate).toBe(48000); + expect(info.length).toBe(Math.round(2.5 * 48000)); + }); + }); +}); diff --git a/tests/context.test.ts b/tests/context.test.ts new file mode 100644 index 0000000..81a65e7 --- /dev/null +++ b/tests/context.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { createContext, ensureRunning, now, resumeContext } from "../src/context.js"; +import { createMockAudioContext } from "./helpers/audio-mocks.js"; + +describe("context", () => { + // ------------------------------------------------------------------------- + // createContext + // ------------------------------------------------------------------------- + describe("createContext", () => { + it("creates an AudioContext (smoke test with mock)", () => { + // createContext calls `new AudioContext()` which isn't available in node. + // We test that the function signature works via the mock-based tests below. + // This test documents the function exists and accepts options. + expect(typeof createContext).toBe("function"); + }); + }); + + // ------------------------------------------------------------------------- + // resumeContext + // ------------------------------------------------------------------------- + describe("resumeContext", () => { + it("calls resume() when state is suspended", async () => { + const ctx = createMockAudioContext(); + (ctx as unknown as { _setState: (s: string) => void })._setState( + "suspended" as AudioContextState, + ); + await resumeContext(ctx); + expect(ctx.resume).toHaveBeenCalled(); + }); + + it("does not call resume() when state is running", async () => { + const ctx = createMockAudioContext(); + await resumeContext(ctx); + expect(ctx.resume).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // ensureRunning + // ------------------------------------------------------------------------- + describe("ensureRunning", () => { + it("calls resume() when state is not running", async () => { + const ctx = createMockAudioContext(); + (ctx as unknown as { _setState: (s: string) => void })._setState( + "suspended" as AudioContextState, + ); + await ensureRunning(ctx); + expect(ctx.resume).toHaveBeenCalled(); + }); + + it("does not call resume() when state is running", async () => { + const ctx = createMockAudioContext(); + await ensureRunning(ctx); + expect(ctx.resume).not.toHaveBeenCalled(); + }); + + it("calls resume() when state is closed", async () => { + const ctx = createMockAudioContext(); + (ctx as unknown as { _setState: (s: string) => void })._setState( + "closed" as AudioContextState, + ); + await ensureRunning(ctx); + expect(ctx.resume).toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // now + // ------------------------------------------------------------------------- + describe("now", () => { + it("returns ctx.currentTime", () => { + const ctx = createMockAudioContext({ currentTime: 1.5 }); + expect(now(ctx)).toBe(1.5); + }); + + it("tracks currentTime changes", () => { + const ctx = createMockAudioContext(); + expect(now(ctx)).toBe(0); + ctx._setCurrentTime(5.0); + expect(now(ctx)).toBe(5.0); + }); + }); +}); diff --git a/tests/emitter.test.ts b/tests/emitter.test.ts index 8082008..6abc191 100644 --- a/tests/emitter.test.ts +++ b/tests/emitter.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createEmitter } from "../src/emitter.js"; describe("createEmitter", () => { diff --git a/tests/fade.test.ts b/tests/fade.test.ts new file mode 100644 index 0000000..aa021e0 --- /dev/null +++ b/tests/fade.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { autoFade, crossfade, fadeIn, fadeOut } from "../src/fade.js"; +import type { MockGainNode } from "./helpers/audio-mocks.js"; +import { createMockAudioContext, createMockPlayback } from "./helpers/audio-mocks.js"; + +/** Create a GainNode mock from MockAudioContext for fade tests. */ +function createTestGain( + ctx: ReturnType, + initialValue = 1, +): MockGainNode { + const gain = ctx.createGain(); + gain.gain.value = initialValue; + return gain; +} + +describe("fade", () => { + // ------------------------------------------------------------------------- + // fadeIn + // ------------------------------------------------------------------------- + describe("fadeIn", () => { + it("ramps from 0 to target with linear curve (default)", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const gain = createTestGain(ctx); + fadeIn(gain as unknown as GainNode, 0.8); + + expect(gain.gain.cancelScheduledValues).toHaveBeenCalledWith(0); + expect(gain.gain.setValueAtTime).toHaveBeenCalledWith(0, 0); + expect(gain.gain.linearRampToValueAtTime).toHaveBeenCalledWith(0.8, 1); + }); + + it("uses exponential curve", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const gain = createTestGain(ctx); + fadeIn(gain as unknown as GainNode, 1, { duration: 2, curve: "exponential" }); + + expect(gain.gain.exponentialRampToValueAtTime).toHaveBeenCalledWith(1, 2); + }); + + it("uses equal-power curve with setValueCurveAtTime", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const gain = createTestGain(ctx); + fadeIn(gain as unknown as GainNode, 1, { duration: 0.5, curve: "equal-power" }); + + expect(gain.gain.setValueCurveAtTime).toHaveBeenCalled(); + const [values, startTime, dur] = gain.gain.setValueCurveAtTime.mock.calls[0]!; + expect(startTime).toBe(0); + expect(dur).toBe(0.5); + expect(values).toBeInstanceOf(Float32Array); + // First value should be close to 0 (from=0) + expect(values[0]).toBeCloseTo(0, 1); + // Last value should be close to 1 (to=1) + expect(values[values.length - 1]).toBeCloseTo(1, 1); + }); + }); + + // ------------------------------------------------------------------------- + // fadeOut + // ------------------------------------------------------------------------- + describe("fadeOut", () => { + it("ramps from current value to 0 with linear curve", () => { + const ctx = createMockAudioContext({ currentTime: 2 }); + const gain = createTestGain(ctx, 0.8); + fadeOut(gain as unknown as GainNode, { duration: 0.5 }); + + expect(gain.gain.cancelScheduledValues).toHaveBeenCalledWith(2); + expect(gain.gain.setValueAtTime).toHaveBeenCalledWith(0.8, 2); + expect(gain.gain.linearRampToValueAtTime).toHaveBeenCalledWith(0, 2.5); + }); + + it("uses exponential curve with EXP_MIN floor", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const gain = createTestGain(ctx, 1); + fadeOut(gain as unknown as GainNode, { curve: "exponential" }); + + // to=0, but exponential clamps to EXP_MIN=0.0001 + expect(gain.gain.exponentialRampToValueAtTime).toHaveBeenCalledWith(0.0001, 1); + }); + }); + + // ------------------------------------------------------------------------- + // crossfade + // ------------------------------------------------------------------------- + describe("crossfade", () => { + it("fades gainA to 0 and gainB to gainA.value", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const gainA = createTestGain(ctx, 0.7); + const gainB = createTestGain(ctx, 0); + + crossfade(gainA as unknown as GainNode, gainB as unknown as GainNode, { duration: 2 }); + + // gainA: from 0.7 to 0 + expect(gainA.gain.setValueAtTime).toHaveBeenCalledWith(0.7, 0); + expect(gainA.gain.linearRampToValueAtTime).toHaveBeenCalledWith(0, 2); + // gainB: from 0 to 0.7 + expect(gainB.gain.setValueAtTime).toHaveBeenCalledWith(0, 0); + expect(gainB.gain.linearRampToValueAtTime).toHaveBeenCalledWith(0.7, 2); + }); + + it("uses 1 as fallback when gainA.value is 0", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const gainA = createTestGain(ctx, 0); + const gainB = createTestGain(ctx, 0); + + crossfade(gainA as unknown as GainNode, gainB as unknown as GainNode); + + // gainB should ramp to 1 (fallback from || 1) + expect(gainB.gain.linearRampToValueAtTime).toHaveBeenCalledWith(1, 1); + }); + }); + + // ------------------------------------------------------------------------- + // autoFade + // ------------------------------------------------------------------------- + describe("autoFade", () => { + it("applies fadeIn immediately and schedules fadeOut near end", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const gain = createTestGain(ctx, 0); + const playback = createMockPlayback({ duration: 10 }); + + const unsub = autoFade( + playback as unknown as import("../src/types.js").Playback, + gain as unknown as GainNode, + { fadeIn: 1, fadeOut: 2 }, + ); + + // fadeIn should have been called (from 0 to 1) + expect(gain.gain.setValueAtTime).toHaveBeenCalled(); + expect(gain.gain.linearRampToValueAtTime).toHaveBeenCalledWith(1, 1); + + // Simulate timeupdate at position 7 (before fadeOut threshold 10-2=8) + gain.gain.cancelScheduledValues.mockClear(); + playback._emit("timeupdate", { position: 7 }); + // fadeOut should NOT have been scheduled yet + expect(gain.gain.cancelScheduledValues).not.toHaveBeenCalled(); + + // Simulate timeupdate at position 8.5 (past threshold) + playback._emit("timeupdate", { position: 8.5 }); + // fadeOut should be scheduled now + expect(gain.gain.cancelScheduledValues).toHaveBeenCalled(); + + // Cleanup + expect(typeof unsub).toBe("function"); + unsub(); + }); + + it("does not double-schedule fadeOut", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const gain = createTestGain(ctx, 0); + const playback = createMockPlayback({ duration: 10 }); + + autoFade( + playback as unknown as import("../src/types.js").Playback, + gain as unknown as GainNode, + { fadeOut: 2 }, + ); + + // Trigger fadeOut threshold twice + playback._emit("timeupdate", { position: 8.5 }); + const callCount1 = gain.gain.cancelScheduledValues.mock.calls.length; + playback._emit("timeupdate", { position: 9.0 }); + const callCount2 = gain.gain.cancelScheduledValues.mock.calls.length; + // Should not have been called again (fadeOutScheduled flag prevents it) + expect(callCount2).toBe(callCount1); + }); + + it("returns unsub that removes timeupdate listener", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const gain = createTestGain(ctx); + const playback = createMockPlayback({ duration: 10 }); + + const unsub = autoFade( + playback as unknown as import("../src/types.js").Playback, + gain as unknown as GainNode, + { fadeOut: 2 }, + ); + + unsub(); + + // After unsub, emitting timeupdate should not trigger any fade + gain.gain.cancelScheduledValues.mockClear(); + playback._emit("timeupdate", { position: 9.0 }); + expect(gain.gain.cancelScheduledValues).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/helpers/audio-mocks.ts b/tests/helpers/audio-mocks.ts new file mode 100644 index 0000000..4a299ba --- /dev/null +++ b/tests/helpers/audio-mocks.ts @@ -0,0 +1,454 @@ +// --------------------------------------------------------------------------- +// Shared mock helpers for audio tests +// --------------------------------------------------------------------------- + +import { vi } from "vitest"; + +// --------------------------------------------------------------------------- +// AudioBufferSourceNode mock +// --------------------------------------------------------------------------- + +export interface MockAudioBufferSourceNode { + buffer: AudioBuffer | null; + playbackRate: { value: number }; + loop: boolean; + loopStart: number; + loopEnd: number; + connect: ReturnType; + disconnect: ReturnType; + start: ReturnType; + stop: ReturnType; + onended: (() => void) | null; +} + +// --------------------------------------------------------------------------- +// GainNode mock +// --------------------------------------------------------------------------- + +export interface MockGainNode { + gain: { + value: number; + setValueAtTime: ReturnType; + linearRampToValueAtTime: ReturnType; + exponentialRampToValueAtTime: ReturnType; + setValueCurveAtTime: ReturnType; + cancelScheduledValues: ReturnType; + }; + context: { currentTime: number }; + connect: ReturnType; + disconnect: ReturnType; +} + +// --------------------------------------------------------------------------- +// AudioContext mock +// --------------------------------------------------------------------------- + +export interface MockAnalyserNode { + frequencyBinCount: number; + fftSize: number; + smoothingTimeConstant: number; + getFloatFrequencyData: ReturnType; + getByteFrequencyData: ReturnType; + connect: ReturnType; + disconnect: ReturnType; +} + +export interface MockBiquadFilterNode { + type: BiquadFilterType; + frequency: { value: number }; + Q: { value: number }; + gain: { value: number }; + connect: ReturnType; + disconnect: ReturnType; +} + +export interface MockStereoPannerNode { + pan: { value: number }; + connect: ReturnType; + disconnect: ReturnType; +} + +export interface MockDynamicsCompressorNode { + threshold: { value: number }; + knee: { value: number }; + ratio: { value: number }; + attack: { value: number }; + release: { value: number }; + connect: ReturnType; + disconnect: ReturnType; +} + +export interface MockAudioContext extends AudioContext { + _sources: MockAudioBufferSourceNode[]; + _gains: MockGainNode[]; + _analysers: MockAnalyserNode[]; + _filters: MockBiquadFilterNode[]; + _panners: MockStereoPannerNode[]; + _compressors: MockDynamicsCompressorNode[]; + _setCurrentTime: (t: number) => void; +} + +export function createMockAudioContext( + overrides?: Partial<{ currentTime: number; sampleRate: number }>, +): MockAudioContext { + let _currentTime = overrides?.currentTime ?? 0; + let _state: AudioContextState = "running"; + const _sampleRate = overrides?.sampleRate ?? 44100; + const sources: MockAudioBufferSourceNode[] = []; + const gains: MockGainNode[] = []; + const analysers: MockAnalyserNode[] = []; + const filters: MockBiquadFilterNode[] = []; + const panners: MockStereoPannerNode[] = []; + const compressors: MockDynamicsCompressorNode[] = []; + + const ctx = { + get currentTime() { + return _currentTime; + }, + get state() { + return _state; + }, + sampleRate: _sampleRate, + destination: {} as AudioDestinationNode, + + resume: vi.fn(async () => { + _state = "running"; + }), + close: vi.fn(async () => { + _state = "closed"; + }), + + createBufferSource: vi.fn(() => { + const src: MockAudioBufferSourceNode = { + buffer: null, + playbackRate: { value: 1 }, + loop: false, + loopStart: 0, + loopEnd: 0, + connect: vi.fn(), + disconnect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + onended: null, + }; + sources.push(src); + return src; + }), + + createGain: vi.fn(() => { + const gain: MockGainNode = { + gain: { + value: 1, + setValueAtTime: vi.fn(), + linearRampToValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + setValueCurveAtTime: vi.fn(), + cancelScheduledValues: vi.fn(), + }, + context: { + get currentTime() { + return _currentTime; + }, + }, + connect: vi.fn(), + disconnect: vi.fn(), + }; + gains.push(gain); + return gain; + }), + + createAnalyser: vi.fn(() => { + const analyser: MockAnalyserNode = { + frequencyBinCount: 1024, + fftSize: 2048, + smoothingTimeConstant: 0.8, + getFloatFrequencyData: vi.fn(), + getByteFrequencyData: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + }; + analysers.push(analyser); + return analyser; + }), + + createBiquadFilter: vi.fn(() => { + const filter: MockBiquadFilterNode = { + type: "lowpass", + frequency: { value: 350 }, + Q: { value: 1 }, + gain: { value: 0 }, + connect: vi.fn(), + disconnect: vi.fn(), + }; + filters.push(filter); + return filter; + }), + + createStereoPanner: vi.fn(() => { + const panner: MockStereoPannerNode = { + pan: { value: 0 }, + connect: vi.fn(), + disconnect: vi.fn(), + }; + panners.push(panner); + return panner; + }), + + createDynamicsCompressor: vi.fn(() => { + const comp: MockDynamicsCompressorNode = { + threshold: { value: -24 }, + knee: { value: 30 }, + ratio: { value: 12 }, + attack: { value: 0.003 }, + release: { value: 0.25 }, + connect: vi.fn(), + disconnect: vi.fn(), + }; + compressors.push(comp); + return comp; + }), + + decodeAudioData: vi.fn(async (arrayBuffer: ArrayBuffer) => { + const length = arrayBuffer.byteLength / 4 || 44100; + return createMockAudioBuffer(length / _sampleRate, _sampleRate); + }), + + createBuffer: vi.fn((channels: number, length: number, sampleRate: number) => { + const channelData: Float32Array[] = []; + for (let i = 0; i < channels; i++) { + channelData.push(new Float32Array(length)); + } + return { + numberOfChannels: channels, + length, + sampleRate, + duration: length / sampleRate, + getChannelData: (ch: number) => channelData[ch]!, + }; + }), + + _sources: sources, + _gains: gains, + _analysers: analysers, + _filters: filters, + _panners: panners, + _compressors: compressors, + _setCurrentTime(t: number) { + _currentTime = t; + }, + _setState(s: AudioContextState) { + _state = s; + }, + } as unknown as MockAudioContext; + + return ctx; +} + +// --------------------------------------------------------------------------- +// AudioBuffer mock +// --------------------------------------------------------------------------- + +export function createMockAudioBuffer( + durationSec: number, + sampleRate = 44100, + channels = 1, +): AudioBuffer { + const length = Math.round(durationSec * sampleRate); + const channelData: Float32Array[] = []; + for (let i = 0; i < channels; i++) { + channelData.push(new Float32Array(length)); + } + return { + numberOfChannels: channels, + length, + sampleRate, + duration: durationSec, + getChannelData: (ch: number) => channelData[ch]!, + } as unknown as AudioBuffer; +} + +// --------------------------------------------------------------------------- +// Worker / URL / Blob stubs for stretcher tests +// --------------------------------------------------------------------------- + +export interface MockWorker { + postMessage: ReturnType; + terminate: ReturnType; + onmessage: ((e: MessageEvent) => void) | null; + onerror: ((e: ErrorEvent) => void) | null; + addEventListener: ReturnType; + removeEventListener: ReturnType; +} + +export function stubWorkerGlobals(): { + workers: MockWorker[]; + simulateWorkerResult: (workerIndex: number, chunkIndex: number, outputLength: number) => void; + simulateWorkerCancel: (workerIndex: number, chunkIndex: number) => void; + simulateWorkerError: (workerIndex: number, chunkIndex: number, error: string) => void; + simulateWorkerCrash: (workerIndex: number, message?: string) => void; +} { + const workers: MockWorker[] = []; + + vi.stubGlobal( + "Worker", + vi.fn(function MockWorkerCtor(this: MockWorker) { + this.postMessage = vi.fn(); + this.terminate = vi.fn(); + this.onmessage = null; + this.onerror = null; + this.addEventListener = vi.fn(); + this.removeEventListener = vi.fn(); + workers.push(this); + }), + ); + + const OriginalURL = globalThis.URL; + const MockURL = Object.assign( + function MockURL(...args: ConstructorParameters) { + return new OriginalURL(...args); + } as unknown as typeof URL, + { + createObjectURL: vi.fn(() => "blob:mock"), + revokeObjectURL: vi.fn(), + prototype: OriginalURL.prototype, + canParse: OriginalURL.canParse, + }, + ); + vi.stubGlobal("URL", MockURL); + + vi.stubGlobal("Blob", vi.fn()); + + function simulateWorkerResult(workerIndex: number, chunkIndex: number, outputLength: number) { + const worker = workers[workerIndex]; + if (!worker?.onmessage) { + throw new Error(`Worker ${workerIndex} has no onmessage handler`); + } + worker.onmessage({ + data: { + type: "result", + chunkIndex, + outputData: [new Float32Array(outputLength)], + outputLength, + }, + } as MessageEvent); + } + + function simulateWorkerCancel(workerIndex: number, chunkIndex: number) { + const worker = workers[workerIndex]; + if (!worker?.onmessage) { + throw new Error(`Worker ${workerIndex} has no onmessage handler`); + } + worker.onmessage({ + data: { type: "cancelled", chunkIndex }, + } as MessageEvent); + } + + function simulateWorkerError(workerIndex: number, chunkIndex: number, error: string) { + const worker = workers[workerIndex]; + if (!worker?.onmessage) { + throw new Error(`Worker ${workerIndex} has no onmessage handler`); + } + worker.onmessage({ + data: { type: "error", chunkIndex, error }, + } as MessageEvent); + } + + function simulateWorkerCrash(workerIndex: number, message = "Worker crashed") { + const worker = workers[workerIndex]; + if (!worker?.onerror) { + throw new Error(`Worker ${workerIndex} has no onerror handler`); + } + const errorEvent = { + message, + preventDefault: vi.fn(), + } as unknown as ErrorEvent; + worker.onerror(errorEvent); + } + + return { + workers, + simulateWorkerResult, + simulateWorkerCancel, + simulateWorkerError, + simulateWorkerCrash, + }; +} + +// --------------------------------------------------------------------------- +// Mock Playback for adapters tests +// --------------------------------------------------------------------------- + +export interface MockPlayback { + getState: ReturnType; + getCurrentTime: ReturnType; + getDuration: ReturnType; + getProgress: ReturnType; + pause: ReturnType; + resume: ReturnType; + togglePlayPause: ReturnType; + seek: ReturnType; + stop: ReturnType; + setPlaybackRate: ReturnType; + setLoop: ReturnType; + on: ReturnType; + off: ReturnType; + dispose: ReturnType; + _emit: (event: string, data?: unknown) => void; +} + +export function createMockPlayback(overrides?: { + state?: string; + currentTime?: number; + duration?: number; + progress?: number; +}): MockPlayback { + const handlers = new Map void>>(); + + const playback: MockPlayback = { + getState: vi.fn(() => overrides?.state ?? "playing"), + getCurrentTime: vi.fn(() => overrides?.currentTime ?? 0), + getDuration: vi.fn(() => overrides?.duration ?? 10), + getProgress: vi.fn(() => overrides?.progress ?? 0), + pause: vi.fn(), + resume: vi.fn(), + togglePlayPause: vi.fn(), + seek: vi.fn(), + stop: vi.fn(), + setPlaybackRate: vi.fn(), + setLoop: vi.fn(), + on: vi.fn((event: string, handler: (data: unknown) => void) => { + if (!handlers.has(event)) handlers.set(event, new Set()); + handlers.get(event)!.add(handler); + return () => { + handlers.get(event)?.delete(handler); + }; + }), + off: vi.fn((event: string, handler: (data: unknown) => void) => { + handlers.get(event)?.delete(handler); + }), + dispose: vi.fn(), + _emit(event: string, data?: unknown) { + const set = handlers.get(event); + if (set) { + for (const h of set) h(data); + } + }, + }; + + return playback; +} + +// --------------------------------------------------------------------------- +// Utility: find active source (with onended handler) +// --------------------------------------------------------------------------- + +export function findActiveSource( + sources: MockAudioBufferSourceNode[], +): MockAudioBufferSourceNode | null { + for (let i = sources.length - 1; i >= 0; i--) { + if (sources[i]!.onended !== null) { + return sources[i]!; + } + } + return null; +} diff --git a/tests/nodes.test.ts b/tests/nodes.test.ts new file mode 100644 index 0000000..f0b207f --- /dev/null +++ b/tests/nodes.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; +import { + chain, + createAnalyser, + createCompressor, + createFilter, + createGain, + createPanner, + disconnectChain, + getFrequencyData, + getFrequencyDataByte, + rampGain, +} from "../src/nodes.js"; +import type { MockAnalyserNode } from "./helpers/audio-mocks.js"; +import { createMockAudioContext } from "./helpers/audio-mocks.js"; + +describe("nodes", () => { + // ------------------------------------------------------------------------- + // createGain + // ------------------------------------------------------------------------- + describe("createGain", () => { + it("creates a GainNode with default value", () => { + const ctx = createMockAudioContext(); + const gain = createGain(ctx); + expect(ctx.createGain).toHaveBeenCalled(); + expect(gain.gain.value).toBe(1); + }); + + it("sets initial value when provided", () => { + const ctx = createMockAudioContext(); + const gain = createGain(ctx, 0.5); + expect(gain.gain.value).toBe(0.5); + }); + }); + + // ------------------------------------------------------------------------- + // rampGain + // ------------------------------------------------------------------------- + describe("rampGain", () => { + it("calls cancelScheduledValues → setValueAtTime → linearRamp in order", () => { + const ctx = createMockAudioContext({ currentTime: 1.0 }); + const gain = createGain(ctx); + rampGain(gain as unknown as GainNode, 0.5, 2.0); + + expect(gain.gain.cancelScheduledValues).toHaveBeenCalledWith(1.0); + expect(gain.gain.setValueAtTime).toHaveBeenCalledWith(gain.gain.value, 1.0); + expect(gain.gain.linearRampToValueAtTime).toHaveBeenCalledWith(0.5, 3.0); + }); + }); + + // ------------------------------------------------------------------------- + // createAnalyser + // ------------------------------------------------------------------------- + describe("createAnalyser", () => { + it("creates an AnalyserNode with defaults", () => { + const ctx = createMockAudioContext(); + const analyser = createAnalyser(ctx); + expect(ctx.createAnalyser).toHaveBeenCalled(); + expect(analyser).toBeDefined(); + }); + + it("sets fftSize and smoothingTimeConstant", () => { + const ctx = createMockAudioContext(); + const analyser = createAnalyser(ctx, { fftSize: 4096, smoothingTimeConstant: 0.5 }); + expect(analyser.fftSize).toBe(4096); + expect(analyser.smoothingTimeConstant).toBe(0.5); + }); + }); + + // ------------------------------------------------------------------------- + // getFrequencyData + // ------------------------------------------------------------------------- + describe("getFrequencyData", () => { + it("returns Float32Array with correct length", () => { + const ctx = createMockAudioContext(); + const analyser = createAnalyser(ctx) as unknown as MockAnalyserNode; + analyser.frequencyBinCount = 512; + const result = getFrequencyData(analyser as unknown as AnalyserNode); + expect(result).toBeInstanceOf(Float32Array); + expect(result.length).toBe(512); + expect(analyser.getFloatFrequencyData).toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // getFrequencyDataByte + // ------------------------------------------------------------------------- + describe("getFrequencyDataByte", () => { + it("returns Uint8Array with correct length", () => { + const ctx = createMockAudioContext(); + const analyser = createAnalyser(ctx) as unknown as MockAnalyserNode; + analyser.frequencyBinCount = 512; + const result = getFrequencyDataByte(analyser as unknown as AnalyserNode); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(512); + expect(analyser.getByteFrequencyData).toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // createFilter + // ------------------------------------------------------------------------- + describe("createFilter", () => { + it("creates a BiquadFilterNode with defaults", () => { + const ctx = createMockAudioContext(); + const filter = createFilter(ctx); + expect(ctx.createBiquadFilter).toHaveBeenCalled(); + expect(filter).toBeDefined(); + }); + + it("sets type, frequency, Q, gain options", () => { + const ctx = createMockAudioContext(); + const filter = createFilter(ctx, { + type: "highpass", + frequency: 1000, + Q: 2, + gain: 3, + }); + expect(filter.type).toBe("highpass"); + expect(filter.frequency.value).toBe(1000); + expect(filter.Q.value).toBe(2); + expect(filter.gain.value).toBe(3); + }); + }); + + // ------------------------------------------------------------------------- + // createPanner + // ------------------------------------------------------------------------- + describe("createPanner", () => { + it("creates a StereoPannerNode with default pan", () => { + const ctx = createMockAudioContext(); + const panner = createPanner(ctx); + expect(ctx.createStereoPanner).toHaveBeenCalled(); + expect(panner.pan.value).toBe(0); + }); + + it("sets pan value", () => { + const ctx = createMockAudioContext(); + const panner = createPanner(ctx, -0.5); + expect(panner.pan.value).toBe(-0.5); + }); + }); + + // ------------------------------------------------------------------------- + // createCompressor + // ------------------------------------------------------------------------- + describe("createCompressor", () => { + it("creates a DynamicsCompressorNode with defaults", () => { + const ctx = createMockAudioContext(); + const comp = createCompressor(ctx); + expect(ctx.createDynamicsCompressor).toHaveBeenCalled(); + expect(comp).toBeDefined(); + }); + + it("sets all 5 parameters", () => { + const ctx = createMockAudioContext(); + const comp = createCompressor(ctx, { + threshold: -50, + knee: 40, + ratio: 20, + attack: 0.01, + release: 0.5, + }); + expect(comp.threshold.value).toBe(-50); + expect(comp.knee.value).toBe(40); + expect(comp.ratio.value).toBe(20); + expect(comp.attack.value).toBe(0.01); + expect(comp.release.value).toBe(0.5); + }); + }); + + // ------------------------------------------------------------------------- + // chain + // ------------------------------------------------------------------------- + describe("chain", () => { + it("connects nodes in serial order", () => { + const ctx = createMockAudioContext(); + const g1 = createGain(ctx); + const g2 = createGain(ctx); + const g3 = createGain(ctx); + chain(g1 as unknown as AudioNode, g2 as unknown as AudioNode, g3 as unknown as AudioNode); + expect(g1.connect).toHaveBeenCalledWith(g2); + expect(g2.connect).toHaveBeenCalledWith(g3); + }); + + it("handles 0 nodes without error", () => { + expect(() => chain()).not.toThrow(); + }); + + it("handles 1 node without error", () => { + const ctx = createMockAudioContext(); + const g = createGain(ctx); + expect(() => chain(g as unknown as AudioNode)).not.toThrow(); + }); + }); + + // ------------------------------------------------------------------------- + // disconnectChain + // ------------------------------------------------------------------------- + describe("disconnectChain", () => { + it("disconnects all nodes", () => { + const ctx = createMockAudioContext(); + const g1 = createGain(ctx); + const g2 = createGain(ctx); + disconnectChain(g1 as unknown as AudioNode, g2 as unknown as AudioNode); + expect(g1.disconnect).toHaveBeenCalled(); + expect(g2.disconnect).toHaveBeenCalled(); + }); + + it("handles disconnect throwing without error", () => { + const ctx = createMockAudioContext(); + const g = createGain(ctx); + g.disconnect.mockImplementation(() => { + throw new Error("already disconnected"); + }); + expect(() => disconnectChain(g as unknown as AudioNode)).not.toThrow(); + }); + }); +}); diff --git a/tests/play/normal-edge-cases.test.ts b/tests/play/normal-edge-cases.test.ts new file mode 100644 index 0000000..30e90cb --- /dev/null +++ b/tests/play/normal-edge-cases.test.ts @@ -0,0 +1,320 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { play } from "../../src/play"; +import { + createMockAudioBuffer, + createMockAudioContext, + type MockAudioContext, +} from "../helpers/audio-mocks"; + +describe("play() – normal edge cases (preservePitch: false)", () => { + let ctx: MockAudioContext; + let buffer: AudioBuffer; + + beforeEach(() => { + vi.useFakeTimers(); + ctx = createMockAudioContext(); + buffer = createMockAudioBuffer(10); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ----------------------------------------------------------------------- + // loopEnd <= loopStart → loopDur guard falls back to non-loop behavior + // ----------------------------------------------------------------------- + + describe("loopEnd <= loopStart (loopDur guard)", () => { + it("loopEnd === loopStart falls back to non-loop position", () => { + const pb = play(ctx, buffer, { + preservePitch: false, + loop: true, + loopStart: 5, + loopEnd: 5, + }); + ctx._setCurrentTime(6); + const pos = pb.getCurrentTime(); + // loopDur = 0 → guard returns Math.min(elapsed, duration) = 6 + expect(pos).toBe(6); + pb.dispose(); + }); + + it("loopEnd < loopStart falls back to non-loop position", () => { + const pb = play(ctx, buffer, { + preservePitch: false, + loop: true, + loopStart: 8, + loopEnd: 3, + }); + ctx._setCurrentTime(5); + const pos = pb.getCurrentTime(); + // loopDur = -5 → guard returns Math.min(elapsed, duration) = 5 + expect(pos).toBe(5); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // playbackRate = 0 → clamped to 1 / ignored by setPlaybackRate + // ----------------------------------------------------------------------- + + describe("playbackRate = 0 (validation guard)", () => { + it("playbackRate: 0 is clamped to 1", () => { + const pb = play(ctx, buffer, { + preservePitch: false, + playbackRate: 0, + }); + ctx._setCurrentTime(5); + const pos = pb.getCurrentTime(); + // rate clamped to 1, elapsed = (5 - 0) * 1 = 5 + expect(pos).toBe(5); + pb.dispose(); + }); + + it("setPlaybackRate(0) is ignored", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + ctx._setCurrentTime(4); + pb.setPlaybackRate(0); + const pos = pb.getCurrentTime(); + // setPlaybackRate(0) is a no-op, rate stays 1, position = 4 + expect(pos).toBe(4); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // sourceNode.stop() double-call exception absorption + // ----------------------------------------------------------------------- + + describe("sourceNode.stop() double call", () => { + it("does not throw when stop() is called on already stopped source", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + const src = ctx._sources[0]!; + // Make stop throw on second call (simulating real behavior) + let callCount = 0; + src.stop = vi.fn(() => { + callCount++; + if (callCount > 1) throw new DOMException("already stopped"); + }); + + // pause calls stopSource which calls stop() + pb.pause(); + expect(callCount).toBe(1); + + // stop calls stopSource again – but source was already nulled by pause + // so the try/catch in stopSource shouldn't even be needed + expect(() => pb.stop()).not.toThrow(); + + pb.dispose(); + }); + + it("absorbs exception from sourceNode.stop() in stopSource", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + const src = ctx._sources[0]!; + src.stop = vi.fn(() => { + throw new DOMException("InvalidStateError"); + }); + // pause → stopSource → try { stop() } catch → safe + expect(() => pb.pause()).not.toThrow(); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // dispose safety: async callback after dispose + // ----------------------------------------------------------------------- + + describe("dispose safety", () => { + it("timeupdate does not fire after dispose", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { + preservePitch: false, + timeupdateInterval: 50, + }); + pb.on("timeupdate", handler); + pb.dispose(); + + ctx._setCurrentTime(5); + vi.advanceTimersByTime(200); + expect(handler).not.toHaveBeenCalled(); + }); + + it("onended does not fire after dispose", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("ended", handler); + + // Save reference to onended before dispose nulls it + const src = ctx._sources[0]!; + const savedOnEnded = src.onended; + + pb.dispose(); + + // Even if somehow onended fires, it should be a no-op + // because dispose nulls onended and clears emitter + if (savedOnEnded) savedOnEnded(); + expect(handler).not.toHaveBeenCalled(); + }); + + it("operations after dispose are safe no-ops", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.dispose(); + + expect(() => { + pb.pause(); + pb.resume(); + pb.togglePlayPause(); + pb.seek(5); + pb.stop(); + pb.setPlaybackRate(2); + pb.setLoop(true); + pb.getCurrentTime(); + pb.getProgress(); + }).not.toThrow(); + }); + }); + + // ----------------------------------------------------------------------- + // Rate change position continuity + // ----------------------------------------------------------------------- + + describe("rate change position continuity", () => { + it("position is continuous across multiple rate changes", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + + // Play at rate 1 for 2 sec → position = 2 + ctx._setCurrentTime(2); + expect(pb.getCurrentTime()).toBeCloseTo(2, 1); + + // Change to rate 2 + pb.setPlaybackRate(2); + expect(pb.getCurrentTime()).toBeCloseTo(2, 1); // still 2 + + // Play 1 more sec at rate 2 → position = 2 + 2 = 4 + ctx._setCurrentTime(3); + expect(pb.getCurrentTime()).toBeCloseTo(4, 1); + + // Change to rate 0.5 + pb.setPlaybackRate(0.5); + expect(pb.getCurrentTime()).toBeCloseTo(4, 1); // still 4 + + // Play 2 more sec at rate 0.5 → position = 4 + 1 = 5 + ctx._setCurrentTime(5); + expect(pb.getCurrentTime()).toBeCloseTo(5, 1); + + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // Seek edge cases + // ----------------------------------------------------------------------- + + describe("seek edge cases", () => { + it("seek to 0 during playback", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + ctx._setCurrentTime(5); + pb.seek(0); + // New source started at offset 0 + const src = ctx._sources[1]!; + expect(src.start).toHaveBeenCalledWith(0, 0); + pb.dispose(); + }); + + it("seek to end of buffer", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.seek(10); + const src = ctx._sources[1]!; + expect(src.start).toHaveBeenCalledWith(0, 10); + pb.dispose(); + }); + + it("rapid sequential seeks only use final position", () => { + const seekHandler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("seek", seekHandler); + + pb.seek(2); + pb.seek(5); + pb.seek(8); + + expect(seekHandler).toHaveBeenCalledTimes(3); + // 3 new sources created (plus the initial one) + expect(ctx.createBufferSource).toHaveBeenCalledTimes(4); + // Last source should start at position 8 + const lastSrc = ctx._sources[3]!; + expect(lastSrc.start).toHaveBeenCalledWith(0, 8); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // Pause/resume position accuracy + // ----------------------------------------------------------------------- + + describe("pause/resume position accuracy", () => { + it("multiple pause/resume cycles preserve position", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + + // Play 2 sec, pause + ctx._setCurrentTime(2); + pb.pause(); + expect(pb.getCurrentTime()).toBeCloseTo(2, 1); + + // Resume, play 3 more sec, pause + ctx._setCurrentTime(5); // 3 sec later + pb.resume(); + // After resume, position should start from 2 + // startedAt = 5 - 2/1 = 3, so at ctx.currentTime=5: elapsed = (5-3)*1 = 2 ✓ + expect(pb.getCurrentTime()).toBeCloseTo(2, 1); + + // Advance 1 sec + ctx._setCurrentTime(6); + expect(pb.getCurrentTime()).toBeCloseTo(3, 1); + + pb.pause(); + expect(pb.getCurrentTime()).toBeCloseTo(3, 1); + + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // Options defaults + // ----------------------------------------------------------------------- + + describe("default options", () => { + it("works with no options (defaults to preservePitch: true)", () => { + // This test verifies the default path goes to stretched playback + // We just verify it doesn't crash + // (Note: this will try to import stretcher engine) + const pb = play(ctx, buffer); + expect(pb.getState()).toBe("playing"); + pb.dispose(); + }); + + it("works with empty options object", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + expect(pb.getState()).toBe("playing"); + expect(pb.getCurrentTime()).toBe(0); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // Custom destination + // ----------------------------------------------------------------------- + + describe("custom destination", () => { + it("connects to custom destination instead of ctx.destination", () => { + const customDest = { connect: vi.fn() } as unknown as AudioNode; + const pb = play(ctx, buffer, { + preservePitch: false, + destination: customDest, + }); + const src = ctx._sources[0]!; + expect(src.connect).toHaveBeenCalledWith(customDest); + pb.dispose(); + }); + }); +}); diff --git a/tests/play/normal-events.test.ts b/tests/play/normal-events.test.ts new file mode 100644 index 0000000..1adea9a --- /dev/null +++ b/tests/play/normal-events.test.ts @@ -0,0 +1,364 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { play } from "../../src/play"; +import { + createMockAudioBuffer, + createMockAudioContext, + type MockAudioContext, +} from "../helpers/audio-mocks"; + +describe("play() – normal events (preservePitch: false)", () => { + let ctx: MockAudioContext; + let buffer: AudioBuffer; + + beforeEach(() => { + vi.useFakeTimers(); + ctx = createMockAudioContext(); + buffer = createMockAudioBuffer(10); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ----------------------------------------------------------------------- + // statechange event + // ----------------------------------------------------------------------- + + describe("statechange", () => { + it("fires on initial play", () => { + const handler = vi.fn(); + // We need to attach the handler after play() since play emits synchronously + // Actually, play() emits statechange("playing") during construction + // We can't catch it unless we use a spy approach, but let's test transitions + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("statechange", handler); + + pb.pause(); + expect(handler).toHaveBeenCalledWith({ state: "paused" }); + + pb.resume(); + expect(handler).toHaveBeenCalledWith({ state: "playing" }); + + pb.stop(); + expect(handler).toHaveBeenCalledWith({ state: "stopped" }); + + expect(handler).toHaveBeenCalledTimes(3); + pb.dispose(); + }); + + it("does not fire duplicate statechange", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("statechange", handler); + pb.stop(); + pb.stop(); // should be no-op + expect(handler).toHaveBeenCalledTimes(1); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // play event + // ----------------------------------------------------------------------- + + describe("play event", () => { + it("fires on construction (can verify via handler count)", () => { + // play event is emitted synchronously in play(), so we can't subscribe before. + // Instead, verify behavior indirectly: play event is emitted once. + // We test that the emitter works by subscribing and triggering via seek + const pb = play(ctx, buffer, { preservePitch: false }); + // play() has already fired, just verify no error + expect(pb.getState()).toBe("playing"); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // pause event + // ----------------------------------------------------------------------- + + describe("pause event", () => { + it("fires when pausing", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("pause", handler); + pb.pause(); + expect(handler).toHaveBeenCalledTimes(1); + pb.dispose(); + }); + + it("does not fire when not playing", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("pause", handler); + pb.stop(); + pb.pause(); // should be no-op + expect(handler).not.toHaveBeenCalled(); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // resume event + // ----------------------------------------------------------------------- + + describe("resume event", () => { + it("fires when resuming from paused", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("resume", handler); + pb.pause(); + pb.resume(); + expect(handler).toHaveBeenCalledTimes(1); + pb.dispose(); + }); + + it("does not fire when already playing", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("resume", handler); + pb.resume(); // already playing + expect(handler).not.toHaveBeenCalled(); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // stop event + // ----------------------------------------------------------------------- + + describe("stop event", () => { + it("fires when stopping", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("stop", handler); + pb.stop(); + expect(handler).toHaveBeenCalledTimes(1); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // seek event + // ----------------------------------------------------------------------- + + describe("seek event", () => { + it("fires with clamped position", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("seek", handler); + pb.seek(5); + expect(handler).toHaveBeenCalledWith({ position: 5 }); + pb.dispose(); + }); + + it("clamps position in seek event", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("seek", handler); + pb.seek(-10); + expect(handler).toHaveBeenCalledWith({ position: 0 }); + pb.seek(100); + expect(handler).toHaveBeenCalledWith({ position: 10 }); + pb.dispose(); + }); + + it("fires seek event even when paused", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("seek", handler); + pb.pause(); + pb.seek(3); + expect(handler).toHaveBeenCalledWith({ position: 3 }); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // ended event + // ----------------------------------------------------------------------- + + describe("ended event", () => { + it("fires when source node ends naturally", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("ended", handler); + + const src = ctx._sources[0]!; + src.onended!(); + + expect(handler).toHaveBeenCalledTimes(1); + pb.dispose(); + }); + + it("does not fire when looping", () => { + const endedHandler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false, loop: true }); + pb.on("ended", endedHandler); + + const src = ctx._sources[0]!; + src.onended!(); + + expect(endedHandler).not.toHaveBeenCalled(); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // loop event + // ----------------------------------------------------------------------- + + describe("loop event", () => { + it("fires when source ends while looping", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false, loop: true }); + pb.on("loop", handler); + + const src = ctx._sources[0]!; + src.onended!(); + + expect(handler).toHaveBeenCalledTimes(1); + pb.dispose(); + }); + + it("fires multiple times on repeated loops", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false, loop: true }); + pb.on("loop", handler); + + const src = ctx._sources[0]!; + src.onended!(); + src.onended!(); + src.onended!(); + + expect(handler).toHaveBeenCalledTimes(3); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // timeupdate event + // ----------------------------------------------------------------------- + + describe("timeupdate event", () => { + it("fires at regular intervals while playing", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { + preservePitch: false, + timeupdateInterval: 100, + }); + pb.on("timeupdate", handler); + + ctx._setCurrentTime(1); + vi.advanceTimersByTime(100); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ position: 1, duration: 10 }); + + ctx._setCurrentTime(2); + vi.advanceTimersByTime(100); + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenCalledWith({ position: 2, duration: 10 }); + + pb.dispose(); + }); + + it("stops firing after pause", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { + preservePitch: false, + timeupdateInterval: 100, + }); + pb.on("timeupdate", handler); + + ctx._setCurrentTime(1); + vi.advanceTimersByTime(100); + expect(handler).toHaveBeenCalledTimes(1); + + pb.pause(); + vi.advanceTimersByTime(500); + expect(handler).toHaveBeenCalledTimes(1); // no more fires + pb.dispose(); + }); + + it("stops firing after stop", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { + preservePitch: false, + timeupdateInterval: 100, + }); + pb.on("timeupdate", handler); + + pb.stop(); + vi.advanceTimersByTime(500); + expect(handler).not.toHaveBeenCalled(); + pb.dispose(); + }); + + it("uses default interval of 50ms", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("timeupdate", handler); + + ctx._setCurrentTime(1); + vi.advanceTimersByTime(50); + expect(handler).toHaveBeenCalledTimes(1); + + pb.dispose(); + }); + + it("resumes firing after resume", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { + preservePitch: false, + timeupdateInterval: 100, + }); + pb.on("timeupdate", handler); + + pb.pause(); + vi.advanceTimersByTime(200); + expect(handler).not.toHaveBeenCalled(); + + pb.resume(); + ctx._setCurrentTime(3); + vi.advanceTimersByTime(100); + expect(handler).toHaveBeenCalledTimes(1); + + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // on / off subscription + // ----------------------------------------------------------------------- + + describe("on / off subscription", () => { + it("on() returns unsubscribe function", () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + const unsub = pb.on("pause", handler); + pb.pause(); + expect(handler).toHaveBeenCalledTimes(1); + + unsub(); + pb.resume(); + pb.pause(); + expect(handler).toHaveBeenCalledTimes(1); // not called again + pb.dispose(); + }); + + it("off() removes specific handler", () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("pause", handler1); + pb.on("pause", handler2); + + pb.off("pause", handler1); + pb.pause(); + + expect(handler1).not.toHaveBeenCalled(); + expect(handler2).toHaveBeenCalledTimes(1); + pb.dispose(); + }); + }); +}); diff --git a/tests/play/normal-playback.test.ts b/tests/play/normal-playback.test.ts new file mode 100644 index 0000000..9e5999b --- /dev/null +++ b/tests/play/normal-playback.test.ts @@ -0,0 +1,496 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { play } from "../../src/play"; +import { + createMockAudioBuffer, + createMockAudioContext, + type MockAudioContext, +} from "../helpers/audio-mocks"; + +describe("play() – normal playback (preservePitch: false)", () => { + let ctx: MockAudioContext; + let buffer: AudioBuffer; + + beforeEach(() => { + vi.useFakeTimers(); + ctx = createMockAudioContext(); + buffer = createMockAudioBuffer(10); // 10 sec + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ----------------------------------------------------------------------- + // Initial state + // ----------------------------------------------------------------------- + + describe("initial state", () => { + it("starts in playing state", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + expect(pb.getState()).toBe("playing"); + pb.dispose(); + }); + + it("creates and starts a source node", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + expect(ctx.createBufferSource).toHaveBeenCalledTimes(1); + const src = ctx._sources[0]!; + expect(src.start).toHaveBeenCalledWith(0, 0); + expect(src.buffer).toBe(buffer); + pb.dispose(); + }); + + it("returns correct duration", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + expect(pb.getDuration()).toBe(10); + pb.dispose(); + }); + + it("starts from specified offset", () => { + const pb = play(ctx, buffer, { preservePitch: false, offset: 3 }); + const src = ctx._sources[0]!; + expect(src.start).toHaveBeenCalledWith(0, 3); + pb.dispose(); + }); + + it("applies initial playbackRate", () => { + const pb = play(ctx, buffer, { preservePitch: false, playbackRate: 2 }); + const src = ctx._sources[0]!; + expect(src.playbackRate.value).toBe(2); + pb.dispose(); + }); + + it("applies initial loop setting", () => { + const pb = play(ctx, buffer, { preservePitch: false, loop: true }); + const src = ctx._sources[0]!; + expect(src.loop).toBe(true); + pb.dispose(); + }); + + it("applies loopStart and loopEnd", () => { + const pb = play(ctx, buffer, { + preservePitch: false, + loop: true, + loopStart: 2, + loopEnd: 8, + }); + const src = ctx._sources[0]!; + expect(src.loopStart).toBe(2); + expect(src.loopEnd).toBe(8); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // pause / resume + // ----------------------------------------------------------------------- + + describe("pause / resume", () => { + it("transitions to paused state", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.pause(); + expect(pb.getState()).toBe("paused"); + pb.dispose(); + }); + + it("stops the source node on pause", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + const src = ctx._sources[0]!; + pb.pause(); + expect(src.stop).toHaveBeenCalled(); + expect(src.onended).toBeNull(); + pb.dispose(); + }); + + it("preserves position on pause", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + ctx._setCurrentTime(3); + pb.pause(); + expect(pb.getCurrentTime()).toBeCloseTo(3, 1); + pb.dispose(); + }); + + it("resume creates a new source and starts from paused position", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + ctx._setCurrentTime(3); + pb.pause(); + pb.resume(); + expect(pb.getState()).toBe("playing"); + expect(ctx.createBufferSource).toHaveBeenCalledTimes(2); + const src2 = ctx._sources[1]!; + // startSource(3) → src.start(0, 3) + expect(src2.start).toHaveBeenCalledWith(0, 3); + pb.dispose(); + }); + + it("pause is no-op when not playing", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.stop(); + const callsBefore = (ctx.createBufferSource as ReturnType).mock.calls.length; + pb.pause(); + expect(pb.getState()).toBe("stopped"); + expect((ctx.createBufferSource as ReturnType).mock.calls.length).toBe( + callsBefore, + ); + pb.dispose(); + }); + + it("resume is no-op when not paused", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + const callsBefore = (ctx.createBufferSource as ReturnType).mock.calls.length; + pb.resume(); + expect(pb.getState()).toBe("playing"); + expect((ctx.createBufferSource as ReturnType).mock.calls.length).toBe( + callsBefore, + ); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // togglePlayPause + // ----------------------------------------------------------------------- + + describe("togglePlayPause", () => { + it("pauses when playing", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.togglePlayPause(); + expect(pb.getState()).toBe("paused"); + pb.dispose(); + }); + + it("resumes when paused", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.pause(); + pb.togglePlayPause(); + expect(pb.getState()).toBe("playing"); + pb.dispose(); + }); + + it("does nothing when stopped", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.stop(); + pb.togglePlayPause(); + expect(pb.getState()).toBe("stopped"); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // seek + // ----------------------------------------------------------------------- + + describe("seek", () => { + it("updates position while playing", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + ctx._setCurrentTime(2); + pb.seek(5); + // After seek, a new source is started at position 5 + expect(ctx.createBufferSource).toHaveBeenCalledTimes(2); + const src2 = ctx._sources[1]!; + expect(src2.start).toHaveBeenCalledWith(0, 5); + pb.dispose(); + }); + + it("updates position while paused without starting playback", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.pause(); + pb.seek(5); + expect(pb.getState()).toBe("paused"); + expect(pb.getCurrentTime()).toBe(5); + // No new source created (only the initial one) + expect(ctx.createBufferSource).toHaveBeenCalledTimes(1); + pb.dispose(); + }); + + it("clamps position to [0, duration]", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.pause(); + pb.seek(-5); + expect(pb.getCurrentTime()).toBe(0); + pb.seek(999); + expect(pb.getCurrentTime()).toBe(10); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // stop + // ----------------------------------------------------------------------- + + describe("stop", () => { + it("transitions to stopped state", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.stop(); + expect(pb.getState()).toBe("stopped"); + pb.dispose(); + }); + + it("resets position to 0", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + ctx._setCurrentTime(5); + pb.stop(); + expect(pb.getCurrentTime()).toBe(0); + pb.dispose(); + }); + + it("stop is no-op when already stopped", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.stop(); + const handler = vi.fn(); + pb.on("stop", handler); + pb.stop(); + expect(handler).not.toHaveBeenCalled(); + pb.dispose(); + }); + + it("can stop from paused state", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.pause(); + pb.stop(); + expect(pb.getState()).toBe("stopped"); + expect(pb.getCurrentTime()).toBe(0); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // getCurrentTime / getProgress + // ----------------------------------------------------------------------- + + describe("getCurrentTime / getProgress", () => { + it("tracks elapsed time during playback", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + ctx._setCurrentTime(4); + expect(pb.getCurrentTime()).toBeCloseTo(4, 1); + expect(pb.getProgress()).toBeCloseTo(0.4, 1); + pb.dispose(); + }); + + it("accounts for playbackRate in elapsed time", () => { + const pb = play(ctx, buffer, { preservePitch: false, playbackRate: 2 }); + ctx._setCurrentTime(3); // 3 sec elapsed at rate 2 → position = 6 + expect(pb.getCurrentTime()).toBeCloseTo(6, 1); + pb.dispose(); + }); + + it("does not exceed duration", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + ctx._setCurrentTime(20); // 20 sec elapsed, but buffer is 10 sec + expect(pb.getCurrentTime()).toBe(10); + pb.dispose(); + }); + + it("returns 0 when stopped", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.stop(); + expect(pb.getCurrentTime()).toBe(0); + expect(pb.getProgress()).toBe(0); + pb.dispose(); + }); + + it("returns 0 progress for zero-duration buffer", () => { + const zeroBuf = createMockAudioBuffer(0); + const pb = play(ctx, zeroBuf, { preservePitch: false }); + expect(pb.getProgress()).toBe(0); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // setPlaybackRate + // ----------------------------------------------------------------------- + + describe("setPlaybackRate", () => { + it("changes rate on current source node", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.setPlaybackRate(1.5); + const src = ctx._sources[0]!; + expect(src.playbackRate.value).toBe(1.5); + pb.dispose(); + }); + + it("maintains position continuity after rate change", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + ctx._setCurrentTime(4); // position = 4 + pb.setPlaybackRate(2); + // After setPlaybackRate: startedAt recalculated so position stays at 4 + expect(pb.getCurrentTime()).toBeCloseTo(4, 1); + // Advance 1 more second at rate 2 → position = 4 + 2 = 6 + ctx._setCurrentTime(5); + expect(pb.getCurrentTime()).toBeCloseTo(6, 1); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // setLoop + // ----------------------------------------------------------------------- + + describe("setLoop", () => { + it("updates loop on current source node", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.setLoop(true); + const src = ctx._sources[0]!; + expect(src.loop).toBe(true); + pb.dispose(); + }); + + it("disables loop on current source node", () => { + const pb = play(ctx, buffer, { + preservePitch: false, + loop: true, + }); + pb.setLoop(false); + const src = ctx._sources[0]!; + expect(src.loop).toBe(false); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // through node chain + // ----------------------------------------------------------------------- + + describe("through node chain", () => { + it("connects source through nodes to destination", () => { + const gain1 = { connect: vi.fn() } as unknown as AudioNode; + const gain2 = { connect: vi.fn() } as unknown as AudioNode; + const pb = play(ctx, buffer, { + preservePitch: false, + through: [gain1, gain2], + }); + const src = ctx._sources[0]!; + expect(src.connect).toHaveBeenCalledWith(gain1); + expect((gain1 as any).connect).toHaveBeenCalledWith(gain2); + expect((gain2 as any).connect).toHaveBeenCalledWith(ctx.destination); + pb.dispose(); + }); + + it("connects directly to destination when through is empty", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + const src = ctx._sources[0]!; + expect(src.connect).toHaveBeenCalledWith(ctx.destination); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // dispose + // ----------------------------------------------------------------------- + + describe("dispose", () => { + it("stops source and clears timers", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + const src = ctx._sources[0]!; + pb.dispose(); + expect(src.stop).toHaveBeenCalled(); + expect(src.disconnect).toHaveBeenCalled(); + }); + + it("prevents further operations", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.dispose(); + // These should be no-ops after dispose + pb.pause(); + pb.resume(); + pb.seek(5); + pb.stop(); + // No additional source nodes created + expect(ctx.createBufferSource).toHaveBeenCalledTimes(1); + }); + + it("double dispose is safe", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + pb.dispose(); + expect(() => pb.dispose()).not.toThrow(); + }); + }); + + // ----------------------------------------------------------------------- + // Loop position tracking + // ----------------------------------------------------------------------- + + describe("loop position tracking", () => { + it("wraps position within loop region", () => { + const pb = play(ctx, buffer, { + preservePitch: false, + loop: true, + loopStart: 2, + loopEnd: 6, + }); + // Loop region: 2–6 (4 sec loop) + // At ctx.currentTime = 7, elapsed = 7, loopDur = 4 + // position = ((7 - 2) % 4) + 2 = (5 % 4) + 2 = 1 + 2 = 3 + ctx._setCurrentTime(7); + expect(pb.getCurrentTime()).toBeCloseTo(3, 1); + pb.dispose(); + }); + + it("wraps position using full duration when no loopStart/loopEnd", () => { + const pb = play(ctx, buffer, { + preservePitch: false, + loop: true, + }); + // loopDur = 10 - 0 = 10 + // At ctx.currentTime = 15, elapsed = 15 + // position = ((15 - 0) % 10) + 0 = 5 + ctx._setCurrentTime(15); + expect(pb.getCurrentTime()).toBeCloseTo(5, 1); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // onended handling + // ----------------------------------------------------------------------- + + describe("onended handling", () => { + it("transitions to stopped and emits ended when source ends naturally", () => { + const endedHandler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("ended", endedHandler); + + const src = ctx._sources[0]!; + src.onended!(); + + expect(pb.getState()).toBe("stopped"); + expect(endedHandler).toHaveBeenCalledTimes(1); + pb.dispose(); + }); + + it("does not emit ended when looping (emits loop instead)", () => { + const endedHandler = vi.fn(); + const loopHandler = vi.fn(); + const pb = play(ctx, buffer, { + preservePitch: false, + loop: true, + }); + pb.on("ended", endedHandler); + pb.on("loop", loopHandler); + + const src = ctx._sources[0]!; + src.onended!(); + + expect(endedHandler).not.toHaveBeenCalled(); + expect(loopHandler).toHaveBeenCalledTimes(1); + expect(pb.getState()).toBe("playing"); + pb.dispose(); + }); + + it("ignores onended when state is not playing", () => { + const endedHandler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: false }); + pb.on("ended", endedHandler); + pb.pause(); + + // Manually trigger the saved handler (shouldn't exist but testing safety) + const src = ctx._sources[0]!; + // onended was nulled on pause, so this simulates if it somehow fires + if (src.onended) src.onended(); + + expect(endedHandler).not.toHaveBeenCalled(); + pb.dispose(); + }); + }); +}); diff --git a/tests/play/normal-stress.test.ts b/tests/play/normal-stress.test.ts new file mode 100644 index 0000000..ccbdd9d --- /dev/null +++ b/tests/play/normal-stress.test.ts @@ -0,0 +1,279 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { play } from "../../src/play"; +import { + createMockAudioBuffer, + createMockAudioContext, + type MockAudioContext, +} from "../helpers/audio-mocks"; + +describe("play() – normal stress & edge cases (preservePitch: false)", () => { + let ctx: MockAudioContext; + let buffer: AudioBuffer; + + beforeEach(() => { + vi.useFakeTimers(); + ctx = createMockAudioContext(); + buffer = createMockAudioBuffer(10); // 10 sec + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ----------------------------------------------------------------------- + // Rapid pause/resume + // ----------------------------------------------------------------------- + + it("rapid pause/resume 50 times does not cause drift", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + ctx._setCurrentTime(2.0); + + for (let i = 0; i < 50; i++) { + pb.pause(); + pb.resume(); + } + + const pos = pb.getCurrentTime(); + // Position should still be close to 2.0 + expect(pos).toBeGreaterThanOrEqual(1.9); + expect(pos).toBeLessThanOrEqual(2.1); + + pb.dispose(); + }); + + // ----------------------------------------------------------------------- + // Rapid seek + // ----------------------------------------------------------------------- + + it("rapid seek 20 times converges to final position", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + + for (let i = 0; i < 20; i++) { + pb.seek(i * 0.5); + } + + // Final seek was to 9.5 + const pos = pb.getCurrentTime(); + expect(pos).toBeGreaterThanOrEqual(9.4); + expect(pos).toBeLessThanOrEqual(10); + + pb.dispose(); + }); + + // ----------------------------------------------------------------------- + // Extreme playbackRate + // ----------------------------------------------------------------------- + + it("playbackRate 0.01 — very slow — position still advances", () => { + const pb = play(ctx, buffer, { preservePitch: false, playbackRate: 0.01 }); + + ctx._setCurrentTime(100); // 100 sec real → 1 sec audio + const pos = pb.getCurrentTime(); + expect(pos).toBeCloseTo(1.0, 0); + + pb.dispose(); + }); + + it("playbackRate 100 — very fast — position clamps to duration", () => { + const pb = play(ctx, buffer, { preservePitch: false, playbackRate: 100 }); + + ctx._setCurrentTime(1); // 1 sec real → 100 sec audio → clamped to 10 + const pos = pb.getCurrentTime(); + expect(pos).toBe(10); + + pb.dispose(); + }); + + // ----------------------------------------------------------------------- + // Very short buffer + // ----------------------------------------------------------------------- + + it("100ms buffer plays and ends correctly", () => { + const shortBuffer = createMockAudioBuffer(0.1); + const pb = play(ctx, shortBuffer, { preservePitch: false }); + const endedHandler = vi.fn(); + pb.on("ended", endedHandler); + + expect(pb.getState()).toBe("playing"); + expect(pb.getDuration()).toBeCloseTo(0.1, 2); + + // Trigger onended + const src = ctx._sources[ctx._sources.length - 1]!; + src.onended!(); + + expect(endedHandler).toHaveBeenCalledTimes(1); + expect(pb.getState()).toBe("stopped"); + + pb.dispose(); + }); + + it("duration=0 buffer clamps position to 0", () => { + const zeroBuffer = createMockAudioBuffer(0); + const pb = play(ctx, zeroBuffer, { preservePitch: false }); + + ctx._setCurrentTime(5); + const pos = pb.getCurrentTime(); + expect(pos).toBe(0); + + pb.dispose(); + }); + + // ----------------------------------------------------------------------- + // setPlaybackRate → seek → resume chain + // ----------------------------------------------------------------------- + + it("setPlaybackRate → seek → resume chain maintains correct position", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + + ctx._setCurrentTime(3); + pb.setPlaybackRate(2.0); + + pb.seek(5.0); + expect(pb.getCurrentTime()).toBeCloseTo(5.0, 1); + + pb.pause(); + expect(pb.getCurrentTime()).toBeCloseTo(5.0, 1); + + pb.resume(); + ctx._setCurrentTime(4); // 1 sec real at rate 2 → +2 sec audio + const pos = pb.getCurrentTime(); + expect(pos).toBeCloseTo(7.0, 0); + + pb.dispose(); + }); + + // ----------------------------------------------------------------------- + // Loop toggle + onended + // ----------------------------------------------------------------------- + + it("loop disable during loop → onended fires at end", () => { + const pb = play(ctx, buffer, { + preservePitch: false, + loop: true, + }); + const endedHandler = vi.fn(); + const loopHandler = vi.fn(); + pb.on("ended", endedHandler); + pb.on("loop", loopHandler); + + // Disable loop + pb.setLoop(false); + + // Trigger onended + const src = ctx._sources[ctx._sources.length - 1]!; + src.onended!(); + + expect(endedHandler).toHaveBeenCalledTimes(1); + expect(loopHandler).not.toHaveBeenCalled(); + + pb.dispose(); + }); + + it("loop enable during playback → loop event fires instead of ended", () => { + const pb = play(ctx, buffer, { + preservePitch: false, + loop: false, + }); + const endedHandler = vi.fn(); + const loopHandler = vi.fn(); + pb.on("ended", endedHandler); + pb.on("loop", loopHandler); + + // Enable loop + pb.setLoop(true); + + // Trigger onended + const src = ctx._sources[ctx._sources.length - 1]!; + src.onended!(); + + expect(loopHandler).toHaveBeenCalledTimes(1); + expect(endedHandler).not.toHaveBeenCalled(); + + pb.dispose(); + }); + + // ----------------------------------------------------------------------- + // Pause + playbackRate change + resume + // ----------------------------------------------------------------------- + + it("pause → setPlaybackRate → resume maintains position", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + + ctx._setCurrentTime(3); + pb.pause(); + const pausePos = pb.getCurrentTime(); + expect(pausePos).toBeCloseTo(3.0, 1); + + // Change rate while paused + pb.setPlaybackRate(0.5); + + pb.resume(); + // Advance 2 sec real → 1 sec audio at rate 0.5 + ctx._setCurrentTime(5); + const pos = pb.getCurrentTime(); + expect(pos).toBeCloseTo(pausePos + 1.0, 0); + + pb.dispose(); + }); + + // ----------------------------------------------------------------------- + // Seek while paused + // ----------------------------------------------------------------------- + + it("seek while paused updates position without starting playback", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + + pb.pause(); + pb.seek(7.0); + + expect(pb.getState()).toBe("paused"); + expect(pb.getCurrentTime()).toBeCloseTo(7.0, 1); + + pb.dispose(); + }); + + // ----------------------------------------------------------------------- + // Double dispose + // ----------------------------------------------------------------------- + + it("double dispose does not throw", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + + pb.dispose(); + expect(() => pb.dispose()).not.toThrow(); + }); + + // ----------------------------------------------------------------------- + // Operations after dispose + // ----------------------------------------------------------------------- + + it("operations after dispose are no-ops", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + + pb.dispose(); + + expect(() => { + pb.pause(); + pb.resume(); + pb.seek(5); + pb.stop(); + }).not.toThrow(); + }); + + // ----------------------------------------------------------------------- + // getProgress consistency + // ----------------------------------------------------------------------- + + it("getProgress is consistent with getCurrentTime and getDuration", () => { + const pb = play(ctx, buffer, { preservePitch: false }); + + ctx._setCurrentTime(3); + const pos = pb.getCurrentTime(); + const dur = pb.getDuration(); + const progress = pb.getProgress(); + + expect(progress).toBeCloseTo(pos / dur, 5); + + pb.dispose(); + }); +}); diff --git a/tests/play/stretched-playback.test.ts b/tests/play/stretched-playback.test.ts new file mode 100644 index 0000000..043fc2f --- /dev/null +++ b/tests/play/stretched-playback.test.ts @@ -0,0 +1,569 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createMockAudioBuffer, + createMockAudioContext, + type MockAudioContext, +} from "../helpers/audio-mocks"; + +// Mock the stretcher engine module before importing play +const mockEngine = { + start: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + seek: vi.fn(), + stop: vi.fn(), + setTempo: vi.fn(), + setLoop: vi.fn(), + getCurrentPosition: vi.fn(() => 0), + getStatus: vi.fn(() => ({ phase: "playing" })), + getSnapshot: vi.fn(() => ({ + tempo: 1, + converting: false, + conversionProgress: 1, + bufferHealth: "healthy", + aheadSeconds: 10, + buffering: false, + chunkStates: [], + currentChunkIndex: 0, + activeWindowStart: 0, + activeWindowEnd: 0, + totalChunks: 1, + windowConversionProgress: 1, + })), + on: vi.fn(() => () => {}), + off: vi.fn(), + dispose: vi.fn(), +}; + +// Capture the event handlers registered via engine.on() +const engineEventHandlers = new Map void)[]>(); + +mockEngine.on.mockImplementation((event: string, handler: (...args: any[]) => void) => { + if (!engineEventHandlers.has(event)) { + engineEventHandlers.set(event, []); + } + engineEventHandlers.get(event)!.push(handler); + return () => { + const handlers = engineEventHandlers.get(event); + if (handlers) { + const idx = handlers.indexOf(handler); + if (idx >= 0) handlers.splice(idx, 1); + } + }; +}); + +function emitEngineEvent(event: string, data?: any) { + const handlers = engineEventHandlers.get(event); + if (handlers) { + for (const handler of handlers) { + handler(data); + } + } +} + +vi.mock("../../src/stretcher/engine.js", () => ({ + createStretcherEngine: vi.fn(() => mockEngine), +})); + +import { play } from "../../src/play"; + +describe("play() – stretched playback (preservePitch: true)", () => { + let ctx: MockAudioContext; + let buffer: AudioBuffer; + + beforeEach(() => { + vi.useFakeTimers(); + ctx = createMockAudioContext(); + buffer = createMockAudioBuffer(60); + vi.clearAllMocks(); + engineEventHandlers.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // Helper: flush microtasks to resolve the dynamic import + async function flushImport() { + await vi.advanceTimersByTimeAsync(0); + } + + // ----------------------------------------------------------------------- + // Initial state + // ----------------------------------------------------------------------- + + describe("initial state", () => { + it("starts in playing state immediately", () => { + const pb = play(ctx, buffer, { preservePitch: true }); + expect(pb.getState()).toBe("playing"); + pb.dispose(); + }); + + it("emits statechange and play events synchronously", () => { + // These events fire before the engine loads + const stateHandler = vi.fn(); + // Can't subscribe before construction, but verify state is playing + const pb = play(ctx, buffer, { preservePitch: true }); + pb.on("statechange", stateHandler); + // Already in playing state, no transition + expect(pb.getState()).toBe("playing"); + pb.dispose(); + }); + + it("returns initial offset as position before engine loads", () => { + const pb = play(ctx, buffer, { preservePitch: true, offset: 5 }); + expect(pb.getCurrentTime()).toBe(5); + pb.dispose(); + }); + + it("returns correct duration", () => { + const pb = play(ctx, buffer, { preservePitch: true }); + expect(pb.getDuration()).toBe(60); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // Engine loading + // ----------------------------------------------------------------------- + + describe("engine loading", () => { + it("creates engine and starts it after dynamic import", async () => { + const { createStretcherEngine } = await import("../../src/stretcher/engine.js"); + const pb = play(ctx, buffer, { preservePitch: true, playbackRate: 1.5 }); + + await flushImport(); + + expect(createStretcherEngine).toHaveBeenCalled(); + expect(mockEngine.start).toHaveBeenCalled(); + + pb.dispose(); + }); + + it("passes options to engine", async () => { + const { createStretcherEngine } = await import("../../src/stretcher/engine.js"); + const dest = {} as AudioNode; + const through1 = {} as AudioNode; + + const pb = play(ctx, buffer, { + preservePitch: true, + playbackRate: 1.5, + offset: 3, + loop: true, + through: [through1], + destination: dest, + }); + + await flushImport(); + + expect(createStretcherEngine).toHaveBeenCalledWith( + ctx, + buffer, + expect.objectContaining({ + tempo: 1.5, + offset: 3, + loop: true, + through: [through1], + destination: dest, + }), + ); + + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // Operations before engine loads (pending operations) + // ----------------------------------------------------------------------- + + describe("operations before engine loads", () => { + it("pause before engine → engine.pause() called after load", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + pb.pause(); + expect(pb.getState()).toBe("paused"); + + await flushImport(); + + expect(mockEngine.pause).toHaveBeenCalled(); + pb.dispose(); + }); + + it("stop before engine → engine.stop() called after load", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + pb.stop(); + expect(pb.getState()).toBe("stopped"); + + await flushImport(); + + expect(mockEngine.stop).toHaveBeenCalled(); + pb.dispose(); + }); + + it("seek before engine → pendingSeek applied after load", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + pb.seek(15); + expect(pb.getCurrentTime()).toBe(15); + + await flushImport(); + + expect(mockEngine.seek).toHaveBeenCalledWith(15); + pb.dispose(); + }); + + it("setPlaybackRate before engine → stored and passed to engine", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + pb.setPlaybackRate(2); + + await flushImport(); + + // The rate is passed during engine creation via options.tempo + // After engine loads, setTempo is not called for initial rate + // because it was passed via options + pb.dispose(); + }); + + it("setLoop before engine → stored and passed to engine", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + pb.setLoop(true); + + await flushImport(); + // Loop value is passed via engine options during creation + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // Delegated operations (after engine loads) + // ----------------------------------------------------------------------- + + describe("delegated operations", () => { + it("pause delegates to engine", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + await flushImport(); + + pb.pause(); + expect(mockEngine.pause).toHaveBeenCalled(); + expect(pb.getState()).toBe("paused"); + pb.dispose(); + }); + + it("resume delegates to engine", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + await flushImport(); + + pb.pause(); + pb.resume(); + expect(mockEngine.resume).toHaveBeenCalled(); + expect(pb.getState()).toBe("playing"); + pb.dispose(); + }); + + it("seek delegates to engine", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + await flushImport(); + + pb.seek(20); + expect(mockEngine.seek).toHaveBeenCalledWith(20); + pb.dispose(); + }); + + it("stop delegates to engine", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + await flushImport(); + + pb.stop(); + expect(mockEngine.stop).toHaveBeenCalled(); + expect(pb.getState()).toBe("stopped"); + pb.dispose(); + }); + + it("setPlaybackRate delegates to engine.setTempo", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + await flushImport(); + + pb.setPlaybackRate(1.5); + expect(mockEngine.setTempo).toHaveBeenCalledWith(1.5); + pb.dispose(); + }); + + it("setLoop delegates to engine.setLoop", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + await flushImport(); + + pb.setLoop(true); + expect(mockEngine.setLoop).toHaveBeenCalledWith(true); + pb.dispose(); + }); + + it("getCurrentTime returns engine position", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + await flushImport(); + + mockEngine.getCurrentPosition.mockReturnValue(25); + expect(pb.getCurrentTime()).toBe(25); + pb.dispose(); + }); + + it("getProgress uses engine position", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + await flushImport(); + + mockEngine.getCurrentPosition.mockReturnValue(30); + expect(pb.getProgress()).toBeCloseTo(0.5, 2); + pb.dispose(); + }); + + it("togglePlayPause works", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + await flushImport(); + + pb.togglePlayPause(); + expect(pb.getState()).toBe("paused"); + + pb.togglePlayPause(); + expect(pb.getState()).toBe("playing"); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // Engine event relay + // ----------------------------------------------------------------------- + + describe("engine event relay", () => { + it("relays buffering event", async () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: true }); + pb.on("buffering", handler); + + await flushImport(); + + emitEngineEvent("buffering", { reason: "initial" }); + expect(handler).toHaveBeenCalledWith({ reason: "initial" }); + pb.dispose(); + }); + + it("relays buffered event", async () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: true }); + pb.on("buffered", handler); + + await flushImport(); + + emitEngineEvent("buffered", { stallDuration: 100 }); + expect(handler).toHaveBeenCalledWith({ stallDuration: 100 }); + pb.dispose(); + }); + + it("relays loop event", async () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: true }); + pb.on("loop", handler); + + await flushImport(); + + emitEngineEvent("loop"); + expect(handler).toHaveBeenCalledTimes(1); + pb.dispose(); + }); + + it("relays ended event and transitions to stopped", async () => { + const endedHandler = vi.fn(); + const stateHandler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: true }); + pb.on("ended", endedHandler); + pb.on("statechange", stateHandler); + + await flushImport(); + + emitEngineEvent("ended"); + expect(pb.getState()).toBe("stopped"); + expect(endedHandler).toHaveBeenCalledTimes(1); + expect(stateHandler).toHaveBeenCalledWith({ state: "stopped" }); + pb.dispose(); + }); + + it("relays fatal error and transitions to stopped", async () => { + const endedHandler = vi.fn(); + const stateHandler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: true }); + pb.on("ended", endedHandler); + pb.on("statechange", stateHandler); + + await flushImport(); + + emitEngineEvent("error", { fatal: true, message: "crash" }); + expect(pb.getState()).toBe("stopped"); + expect(endedHandler).toHaveBeenCalled(); + expect(stateHandler).toHaveBeenCalledWith({ state: "stopped" }); + pb.dispose(); + }); + + it("non-fatal error does not change state", async () => { + const stateHandler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: true }); + pb.on("statechange", stateHandler); + + await flushImport(); + + emitEngineEvent("error", { fatal: false, message: "retry" }); + expect(pb.getState()).toBe("playing"); + expect(stateHandler).not.toHaveBeenCalled(); + pb.dispose(); + }); + + it("does not relay events after dispose", async () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: true }); + pb.on("buffering", handler); + + await flushImport(); + + pb.dispose(); + emitEngineEvent("buffering", { reason: "underrun" }); + expect(handler).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // stop() then engine ended does not double-fire statechange + // ----------------------------------------------------------------------- + + describe("stop + ended race condition", () => { + it("stop() then engine ended does not double-fire statechange", async () => { + const stateHandler = vi.fn(); + const pb = play(ctx, buffer, { preservePitch: true }); + pb.on("statechange", stateHandler); + + await flushImport(); + + pb.stop(); + expect(stateHandler).toHaveBeenCalledTimes(1); + expect(stateHandler).toHaveBeenCalledWith({ state: "stopped" }); + + // Engine fires ended after stop — setState guard prevents double-fire + emitEngineEvent("ended"); + expect(stateHandler).toHaveBeenCalledTimes(1); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // Dispose + // ----------------------------------------------------------------------- + + describe("dispose", () => { + it("disposes engine", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + await flushImport(); + + pb.dispose(); + expect(mockEngine.dispose).toHaveBeenCalled(); + }); + + it("dispose before engine loads is safe", () => { + const pb = play(ctx, buffer, { preservePitch: true }); + expect(() => pb.dispose()).not.toThrow(); + }); + + it("double dispose is safe", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + await flushImport(); + + pb.dispose(); + expect(() => pb.dispose()).not.toThrow(); + }); + + it("operations after dispose are no-ops", async () => { + const pb = play(ctx, buffer, { preservePitch: true }); + await flushImport(); + + pb.dispose(); + + expect(() => { + pb.pause(); + pb.resume(); + pb.seek(5); + pb.stop(); + pb.setPlaybackRate(2); + pb.setLoop(true); + }).not.toThrow(); + }); + }); + + // ----------------------------------------------------------------------- + // Timeupdate timer + // ----------------------------------------------------------------------- + + describe("timeupdate timer", () => { + it("fires timeupdate while playing", async () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { + preservePitch: true, + timeupdateInterval: 100, + }); + pb.on("timeupdate", handler); + + await flushImport(); + + mockEngine.getCurrentPosition.mockReturnValue(5); + vi.advanceTimersByTime(100); + expect(handler).toHaveBeenCalledWith({ position: 5, duration: 60 }); + pb.dispose(); + }); + + it("stops timeupdate on pause", async () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { + preservePitch: true, + timeupdateInterval: 100, + }); + pb.on("timeupdate", handler); + + await flushImport(); + + pb.pause(); + vi.advanceTimersByTime(500); + expect(handler).not.toHaveBeenCalled(); + pb.dispose(); + }); + + it("stops timeupdate on stop", async () => { + const handler = vi.fn(); + const pb = play(ctx, buffer, { + preservePitch: true, + timeupdateInterval: 100, + }); + pb.on("timeupdate", handler); + + await flushImport(); + + pb.stop(); + vi.advanceTimersByTime(500); + expect(handler).not.toHaveBeenCalled(); + pb.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // _getStretcherSnapshot + // ----------------------------------------------------------------------- + + describe("_getStretcherSnapshot", () => { + it("returns null before engine loads", () => { + const pb = play(ctx, buffer, { preservePitch: true }) as any; + expect(pb._getStretcherSnapshot()).toBeNull(); + pb.dispose(); + }); + + it("returns engine snapshot after load", async () => { + const pb = play(ctx, buffer, { preservePitch: true }) as any; + await flushImport(); + + const snapshot = pb._getStretcherSnapshot(); + expect(snapshot).not.toBeNull(); + expect(snapshot.tempo).toBe(1); + pb.dispose(); + }); + }); +}); diff --git a/tests/playback-position.test.ts b/tests/playback-position.test.ts new file mode 100644 index 0000000..37156bd --- /dev/null +++ b/tests/playback-position.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { calcLoopPosition, calcPlaybackPosition } from "../src/playback-position"; + +describe("calcLoopPosition", () => { + it("wraps elapsed within loop region (full loop)", () => { + // elapsed=25, loop={0,10} → 25 % 10 = 5 + expect(calcLoopPosition(25, 0, 10)).toBe(5); + }); + + it("wraps elapsed within partial loop region", () => { + // elapsed=7, loop={2,6}, loopDur=4 → (7-2)%4 + 2 = 1+2 = 3 + expect(calcLoopPosition(7, 2, 6)).toBe(3); + }); + + it("handles elapsed exactly at loopStart", () => { + expect(calcLoopPosition(3, 3, 7)).toBe(3); + }); + + it("handles elapsed exactly at loopEnd", () => { + // elapsed=7, loop={3,7}, loopDur=4 → (7-3)%4 + 3 = 0+3 = 3 + expect(calcLoopPosition(7, 3, 7)).toBe(3); + }); + + it("handles elapsed before loopStart (potential negative modulo bug)", () => { + // elapsed=0, loop={3,7}, loopDur=4 + // Bug with JS %: (0-3)%4 = -3, -3+3 = 0 (outside loop region!) + // Fixed: ((-3 % 4) + 4) % 4 = 1, 1+3 = 4 (inside loop region) + const result = calcLoopPosition(0, 3, 7); + expect(result).not.toBeNull(); + expect(result!).toBeGreaterThanOrEqual(3); + expect(result!).toBeLessThan(7); + expect(result).toBe(4); + }); + + it("handles elapsed=1 before loopStart=3 (negative offset)", () => { + // elapsed=1, loop={3,7}, loopDur=4 + // offset = 1-3 = -2, ((-2%4)+4)%4 = 2, 2+3 = 5 + const result = calcLoopPosition(1, 3, 7); + expect(result).not.toBeNull(); + expect(result!).toBeGreaterThanOrEqual(3); + expect(result!).toBeLessThan(7); + expect(result).toBe(5); + }); + + it("returns null when loopDur <= 0 (loopStart === loopEnd)", () => { + expect(calcLoopPosition(5, 3, 3)).toBeNull(); + }); + + it("returns null when loopEnd < loopStart (negative loopDur)", () => { + expect(calcLoopPosition(10, 5, 2)).toBeNull(); + }); + + it("handles floating point values", () => { + const result = calcLoopPosition(3.7, 1.2, 2.8); + // loopDur = 1.6, offset = 3.7-1.2 = 2.5, 2.5 % 1.6 ≈ 0.9, 0.9+1.2 = 2.1 + expect(result).not.toBeNull(); + expect(result!).toBeCloseTo(2.1, 10); + }); + + it("handles very small loop duration", () => { + const result = calcLoopPosition(100, 0, 0.001); + expect(result).not.toBeNull(); + expect(result!).toBeGreaterThanOrEqual(0); + expect(result!).toBeLessThan(0.001); + }); +}); + +describe("calcPlaybackPosition", () => { + it("returns 0 for stopped state", () => { + expect(calcPlaybackPosition("stopped", 5, 10, 3, false, undefined, undefined)).toBe(0); + }); + + it("returns pausedAt for paused state", () => { + expect(calcPlaybackPosition("paused", 0, 10, 4.5, false, undefined, undefined)).toBe(4.5); + }); + + it("returns elapsed for playing state (non-looping)", () => { + expect(calcPlaybackPosition("playing", 3, 10, 0, false, undefined, undefined)).toBe(3); + }); + + it("clamps elapsed to duration when past end", () => { + expect(calcPlaybackPosition("playing", 15, 10, 0, false, undefined, undefined)).toBe(10); + }); + + it("clamps negative elapsed to 0", () => { + expect(calcPlaybackPosition("playing", -2, 10, 0, false, undefined, undefined)).toBe(0); + }); + + it("returns 0 when elapsed is 0", () => { + expect(calcPlaybackPosition("playing", 0, 10, 0, false, undefined, undefined)).toBe(0); + }); + + it("delegates to calcLoopPosition when looping", () => { + // elapsed=25, loop={0,10} → 5 + expect(calcPlaybackPosition("playing", 25, 30, 0, true, 0, 10)).toBe(5); + }); + + it("uses defaults for undefined loopStart/loopEnd", () => { + // loopStart=0, loopEnd=duration=10 → elapsed=25 % 10 = 5 + expect(calcPlaybackPosition("playing", 25, 10, 0, true, undefined, undefined)).toBe(5); + }); + + it("handles looping with elapsed before loopStart", () => { + // elapsed=0, loop={3,7} → inside loop region + const result = calcPlaybackPosition("playing", 0, 10, 0, true, 3, 7); + expect(result).toBeGreaterThanOrEqual(3); + expect(result).toBeLessThan(7); + }); + + it("falls back to clamped position when loopDur <= 0 (loopStart === loopEnd)", () => { + // loopDur = 0 → fall back to Math.min(Math.max(elapsed, 0), duration) + expect(calcPlaybackPosition("playing", 6, 10, 0, true, 5, 5)).toBe(6); + }); + + it("falls back to clamped position when loopEnd < loopStart", () => { + // loopDur < 0 → fall back to Math.min(Math.max(elapsed, 0), duration) + expect(calcPlaybackPosition("playing", 5, 10, 0, true, 8, 3)).toBe(5); + }); + + it("handles NaN elapsed gracefully", () => { + const result = calcPlaybackPosition("playing", NaN, 10, 0, false, undefined, undefined); + // NaN clamped: Math.min(Math.max(NaN, 0), 10) → NaN + expect(result).toBeNaN(); + }); + + it("handles duration=0", () => { + expect(calcPlaybackPosition("playing", 5, 0, 0, false, undefined, undefined)).toBe(0); + }); + + it("handles looping with loopDur=0 when duration=0", () => { + // loopStart=0, loopEnd=0 (=duration) → loopDur=0 → fallback → Math.min(Math.max(5,0),0)=0 + const result = calcPlaybackPosition("playing", 5, 0, 0, true, 0, 0); + expect(result).toBe(0); + }); +}); diff --git a/tests/playback-state.test.ts b/tests/playback-state.test.ts new file mode 100644 index 0000000..008f00a --- /dev/null +++ b/tests/playback-state.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createPlaybackStateManager } from "../src/playback-state"; + +describe("createPlaybackStateManager", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("does not fire statechange when setState is called with the same state", () => { + const onStateChange = vi.fn(); + const sm = createPlaybackStateManager({ + initialState: "playing", + onStateChange, + onTimerTick: vi.fn(), + timerInterval: 50, + }); + const changed = sm.setState("playing"); + expect(changed).toBe(false); + expect(onStateChange).not.toHaveBeenCalled(); + }); + + it("fires statechange when setState is called with a different state", () => { + const onStateChange = vi.fn(); + const sm = createPlaybackStateManager({ + initialState: "playing", + onStateChange, + onTimerTick: vi.fn(), + timerInterval: 50, + }); + const changed = sm.setState("paused"); + expect(changed).toBe(true); + expect(onStateChange).toHaveBeenCalledWith("paused"); + expect(sm.getState()).toBe("paused"); + }); + + it("does not create duplicate timers on repeated startTimer calls", () => { + const onTimerTick = vi.fn(); + const sm = createPlaybackStateManager({ + initialState: "playing", + onStateChange: vi.fn(), + onTimerTick, + timerInterval: 50, + }); + sm.startTimer(); + sm.startTimer(); + sm.startTimer(); + vi.advanceTimersByTime(100); + // Should fire ~2 times for 100ms interval with 50ms tick (not 6 times) + expect(onTimerTick.mock.calls.length).toBe(2); + }); + + it("does not fire callback after stopTimer", () => { + const onTimerTick = vi.fn(); + const sm = createPlaybackStateManager({ + initialState: "playing", + onStateChange: vi.fn(), + onTimerTick, + timerInterval: 50, + }); + sm.startTimer(); + vi.advanceTimersByTime(50); + expect(onTimerTick).toHaveBeenCalledTimes(1); + sm.stopTimer(); + vi.advanceTimersByTime(200); + expect(onTimerTick).toHaveBeenCalledTimes(1); + }); + + it("isDisposed returns true after markDisposed", () => { + const sm = createPlaybackStateManager({ + initialState: "playing", + onStateChange: vi.fn(), + onTimerTick: vi.fn(), + timerInterval: 50, + }); + expect(sm.isDisposed()).toBe(false); + sm.markDisposed(); + expect(sm.isDisposed()).toBe(true); + }); + + it("stops timer on markDisposed", () => { + const onTimerTick = vi.fn(); + const sm = createPlaybackStateManager({ + initialState: "playing", + onStateChange: vi.fn(), + onTimerTick, + timerInterval: 50, + }); + sm.startTimer(); + vi.advanceTimersByTime(50); + expect(onTimerTick).toHaveBeenCalledTimes(1); + sm.markDisposed(); + vi.advanceTimersByTime(200); + expect(onTimerTick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/player.test.ts b/tests/player.test.ts index 12442c1..fe416d2 100644 --- a/tests/player.test.ts +++ b/tests/player.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; // --- Mock all delegated modules --- @@ -91,16 +91,16 @@ function mockAudioContext() { // --- Import after mocks --- -import { WaaPlayer } from "../src/player.js"; -import * as contextMod from "../src/context.js"; +import * as adaptersMod from "../src/adapters.js"; import * as bufferMod from "../src/buffer.js"; -import * as playMod from "../src/play.js"; -import * as nodesMod from "../src/nodes.js"; -import * as waveformMod from "../src/waveform.js"; +import * as contextMod from "../src/context.js"; import * as fadeMod from "../src/fade.js"; +import * as nodesMod from "../src/nodes.js"; +import * as playMod from "../src/play.js"; +import { WaaPlayer } from "../src/player.js"; import * as schedulerMod from "../src/scheduler.js"; import * as synthMod from "../src/synth.js"; -import * as adaptersMod from "../src/adapters.js"; +import * as waveformMod from "../src/waveform.js"; // --------------------------------------------------------------------------- // Constructor @@ -194,20 +194,13 @@ describe("Buffer delegation", () => { it("load() delegates to loadBuffer with ctx", async () => { const opts = { onProgress: vi.fn() }; await player.load("test.mp3", opts); - expect(bufferMod.loadBuffer).toHaveBeenCalledWith( - player.ctx, - "test.mp3", - opts, - ); + expect(bufferMod.loadBuffer).toHaveBeenCalledWith(player.ctx, "test.mp3", opts); }); it("loadFromBlob() delegates to loadBufferFromBlob", async () => { const blob = {} as Blob; await player.loadFromBlob(blob); - expect(bufferMod.loadBufferFromBlob).toHaveBeenCalledWith( - player.ctx, - blob, - ); + expect(bufferMod.loadBufferFromBlob).toHaveBeenCalledWith(player.ctx, blob); }); it("loadAll() delegates to loadBuffers", async () => { @@ -399,10 +392,7 @@ describe("Scheduler delegation", () => { it("createScheduler() injects ctx", () => { const opts = { lookahead: 0.2 }; player.createScheduler(opts); - expect(schedulerMod.createScheduler).toHaveBeenCalledWith( - player.ctx, - opts, - ); + expect(schedulerMod.createScheduler).toHaveBeenCalledWith(player.ctx, opts); }); it("createClock() injects ctx", () => { @@ -425,11 +415,7 @@ describe("Synth delegation", () => { it("createSineBuffer() injects ctx", () => { player.createSineBuffer(440, 1); - expect(synthMod.createSineBuffer).toHaveBeenCalledWith( - player.ctx, - 440, - 1, - ); + expect(synthMod.createSineBuffer).toHaveBeenCalledWith(player.ctx, 440, 1); }); it("createNoiseBuffer() injects ctx", () => { @@ -439,11 +425,7 @@ describe("Synth delegation", () => { it("createClickBuffer() injects ctx", () => { player.createClickBuffer(1000, 0.01); - expect(synthMod.createClickBuffer).toHaveBeenCalledWith( - player.ctx, - 1000, - 0.01, - ); + expect(synthMod.createClickBuffer).toHaveBeenCalledWith(player.ctx, 1000, 0.01); }); }); diff --git a/tests/scheduler.test.ts b/tests/scheduler.test.ts new file mode 100644 index 0000000..4ae4e07 --- /dev/null +++ b/tests/scheduler.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createClock, createScheduler } from "../src/scheduler.js"; +import { createMockAudioContext } from "./helpers/audio-mocks.js"; + +describe("scheduler", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ------------------------------------------------------------------------- + // createScheduler + // ------------------------------------------------------------------------- + describe("createScheduler", () => { + it("fires callback when event time is within lookahead", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const scheduler = createScheduler(ctx, { lookahead: 0.1, interval: 25 }); + + const callback = vi.fn(); + scheduler.schedule("ev1", 0.05, callback); + scheduler.start(); + + // Advance timer to trigger tick + vi.advanceTimersByTime(25); + + expect(callback).toHaveBeenCalledWith(0.05); + }); + + it("does not fire callback before event time enters lookahead", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const scheduler = createScheduler(ctx, { lookahead: 0.1, interval: 25 }); + + const callback = vi.fn(); + scheduler.schedule("ev1", 0.5, callback); + scheduler.start(); + + vi.advanceTimersByTime(25); + expect(callback).not.toHaveBeenCalled(); + + // Advance currentTime to bring event into lookahead + ctx._setCurrentTime(0.45); + vi.advanceTimersByTime(25); + expect(callback).toHaveBeenCalledWith(0.5); + }); + + it("removes event after firing", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const scheduler = createScheduler(ctx, { lookahead: 0.1, interval: 25 }); + + const callback = vi.fn(); + scheduler.schedule("ev1", 0.05, callback); + scheduler.start(); + + vi.advanceTimersByTime(25); + expect(callback).toHaveBeenCalledTimes(1); + + // Next tick should not fire again + vi.advanceTimersByTime(25); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("cancel removes a scheduled event", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const scheduler = createScheduler(ctx, { lookahead: 0.1, interval: 25 }); + + const callback = vi.fn(); + scheduler.schedule("ev1", 0.05, callback); + scheduler.cancel("ev1"); + scheduler.start(); + + vi.advanceTimersByTime(25); + expect(callback).not.toHaveBeenCalled(); + }); + + it("start is idempotent (multiple calls)", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const scheduler = createScheduler(ctx, { lookahead: 0.1, interval: 25 }); + + const callback = vi.fn(); + scheduler.schedule("ev1", 0.05, callback); + scheduler.start(); + scheduler.start(); // should not create a second interval + + vi.advanceTimersByTime(25); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("stop clears the interval", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const scheduler = createScheduler(ctx, { lookahead: 0.1, interval: 25 }); + + const callback = vi.fn(); + scheduler.schedule("ev1", 0.05, callback); + scheduler.start(); + scheduler.stop(); + + vi.advanceTimersByTime(100); + expect(callback).not.toHaveBeenCalled(); + }); + + it("dispose stops and clears all events", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const scheduler = createScheduler(ctx, { lookahead: 0.1, interval: 25 }); + + const callback = vi.fn(); + scheduler.schedule("ev1", 0.05, callback); + scheduler.start(); + scheduler.dispose(); + + vi.advanceTimersByTime(100); + expect(callback).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // createClock + // ------------------------------------------------------------------------- + describe("createClock", () => { + it("uses default BPM of 120", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const clock = createClock(ctx); + expect(clock.getBpm()).toBe(120); + }); + + it("beatToTime converts beats to seconds", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const clock = createClock(ctx, { bpm: 120 }); + // At 120 BPM, 1 beat = 0.5 seconds + expect(clock.beatToTime(0)).toBe(0); + expect(clock.beatToTime(1)).toBe(0.5); + expect(clock.beatToTime(4)).toBe(2); + }); + + it("getCurrentBeat returns current beat based on currentTime", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const clock = createClock(ctx, { bpm: 120 }); + expect(clock.getCurrentBeat()).toBe(0); + ctx._setCurrentTime(0.5); + expect(clock.getCurrentBeat()).toBe(1); + ctx._setCurrentTime(1.0); + expect(clock.getCurrentBeat()).toBe(2); + }); + + it("getNextBeatTime returns next beat boundary", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const clock = createClock(ctx, { bpm: 120 }); + // At time 0, current beat = 0, next beat = 1 → time 0.5 + expect(clock.getNextBeatTime()).toBe(0.5); + + ctx._setCurrentTime(0.25); + // Current beat = 0.5, ceil = 1 → time 0.5 + expect(clock.getNextBeatTime()).toBe(0.5); + }); + + it("getNextBeatTime returns beat+1 when exactly on a beat boundary", () => { + const ctx = createMockAudioContext({ currentTime: 0.5 }); + const clock = createClock(ctx, { bpm: 120 }); + // startTime=0.5, currentTime=0.5 → current beat = 0 + // ceil(0) === 0 → next = 0 + 1 = 1 → time = 0.5 + 0.5 = 1.0 + expect(clock.getNextBeatTime()).toBe(1.0); + }); + + it("setBpm updates BPM", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const clock = createClock(ctx, { bpm: 120 }); + clock.setBpm(60); + expect(clock.getBpm()).toBe(60); + // At 60 BPM, 1 beat = 1 second + expect(clock.beatToTime(1)).toBe(1.0); + }); + + it("handles getNextBeatTime with floating-point precision", () => { + const ctx = createMockAudioContext({ currentTime: 0 }); + const clock = createClock(ctx, { bpm: 120 }); + // Simulate a floating-point edge case where currentBeat ≈ integer + ctx._setCurrentTime(1.0); // beat = 2.0 exactly + const nextBeatTime = clock.getNextBeatTime(); + // Should be beat 3 → time 1.5 + expect(nextBeatTime).toBe(1.5); + }); + }); +}); diff --git a/tests/stretched-playback-import-error.test.ts b/tests/stretched-playback-import-error.test.ts new file mode 100644 index 0000000..eced6fb --- /dev/null +++ b/tests/stretched-playback-import-error.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the stretcher engine module to simulate import failures +vi.mock("../src/stretcher/engine.js", () => ({ + createStretcherEngine: vi.fn(() => { + throw new Error("engine load failed"); + }), +})); + +import { play } from "../src/play"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockAudioContext(): AudioContext { + return { + currentTime: 0, + destination: {} as AudioDestinationNode, + createBufferSource: vi.fn(() => ({ + buffer: null, + playbackRate: { value: 1 }, + connect: vi.fn(), + disconnect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + onended: null, + })), + createGain: vi.fn(() => ({ + gain: { + value: 1, + setValueAtTime: vi.fn(), + setValueCurveAtTime: vi.fn(), + }, + connect: vi.fn(), + disconnect: vi.fn(), + })), + } as unknown as AudioContext; +} + +function createMockAudioBuffer(durationSec: number): AudioBuffer { + const length = Math.round(durationSec * 44100); + const data = new Float32Array(length); + return { + numberOfChannels: 1, + length, + sampleRate: 44100, + duration: durationSec, + getChannelData: () => data, + } as unknown as AudioBuffer; +} + +// Vitest 4.x の fake timers は動的 import() の Promise 解決をブロックするため、 +// real timers で短い待機を使う +function waitForImportCatch(): Promise { + return new Promise((r) => setTimeout(r, 50)); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("createStretchedPlayback — dynamic import .catch()", () => { + let ctx: AudioContext; + let buffer: AudioBuffer; + + beforeEach(() => { + ctx = createMockAudioContext(); + buffer = createMockAudioBuffer(10); + }); + + it("createStretcherEngine が throw → state が stopped に遷移し statechange/ended が emit される", async () => { + const pb = play(ctx, buffer); + + const statechangeHandler = vi.fn(); + const endedHandler = vi.fn(); + pb.on("statechange", statechangeHandler); + pb.on("ended", endedHandler); + + await waitForImportCatch(); + + expect(statechangeHandler).toHaveBeenCalledWith({ state: "stopped" }); + expect(endedHandler).toHaveBeenCalledTimes(1); + expect(pb.getState()).toBe("stopped"); + }); + + it("catch 後の getCurrentTime/getDuration/getProgress が正しい値を返す", async () => { + const pb = play(ctx, buffer); + + await waitForImportCatch(); + + expect(pb.getCurrentTime()).toBe(0); + expect(pb.getDuration()).toBe(10); + expect(pb.getProgress()).toBe(0); + }); + + it("catch 発火前に dispose() → イベント emit されず state 変化なし", async () => { + const pb = play(ctx, buffer); + + const statechangeHandler = vi.fn(); + const endedHandler = vi.fn(); + pb.on("statechange", statechangeHandler); + pb.on("ended", endedHandler); + + pb.dispose(); + + await waitForImportCatch(); + + expect(statechangeHandler).not.toHaveBeenCalled(); + expect(endedHandler).not.toHaveBeenCalled(); + }); + + it("catch 発火前に stop() → catch では statechange が二重発火しない", async () => { + const pb = play(ctx, buffer); + + const statechangeHandler = vi.fn(); + const endedHandler = vi.fn(); + pb.on("statechange", statechangeHandler); + pb.on("ended", endedHandler); + + pb.stop(); + statechangeHandler.mockClear(); + endedHandler.mockClear(); + + await waitForImportCatch(); + + // stop() already set state="stopped", so setState guard prevents double-fire + expect(statechangeHandler).not.toHaveBeenCalled(); + // ended is still emitted from catch handler + expect(endedHandler).toHaveBeenCalledTimes(1); + }); + + it("catch 発火前に pause() → catch で state が stopped に上書きされる", async () => { + const pb = play(ctx, buffer); + + const statechangeHandler = vi.fn(); + const endedHandler = vi.fn(); + pb.on("statechange", statechangeHandler); + pb.on("ended", endedHandler); + + pb.pause(); + statechangeHandler.mockClear(); + endedHandler.mockClear(); + + await waitForImportCatch(); + + expect(pb.getState()).toBe("stopped"); + expect(statechangeHandler).toHaveBeenCalledWith({ state: "stopped" }); + expect(endedHandler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/stretcher/buffer-monitor.test.ts b/tests/stretcher/buffer-monitor.test.ts index 995f156..30cd593 100644 --- a/tests/stretcher/buffer-monitor.test.ts +++ b/tests/stretcher/buffer-monitor.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { createBufferMonitor } from "../../src/stretcher/buffer-monitor"; import type { ChunkInfo } from "../../src/stretcher/types"; diff --git a/tests/stretcher/chunk-player-advanced.test.ts b/tests/stretcher/chunk-player-advanced.test.ts new file mode 100644 index 0000000..b759fd2 --- /dev/null +++ b/tests/stretcher/chunk-player-advanced.test.ts @@ -0,0 +1,666 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createChunkPlayer } from "../../src/stretcher/chunk-player"; +import { createMockAudioContext, type MockAudioContext } from "../helpers/audio-mocks"; + +describe("createChunkPlayer – advanced", () => { + let ctx: MockAudioContext; + + function createMockBuffer(durationSec: number): AudioBuffer { + const length = Math.round(durationSec * 44100); + const data = new Float32Array(length); + return { + numberOfChannels: 1, + length, + sampleRate: 44100, + duration: durationSec, + getChannelData: () => data, + } as unknown as AudioBuffer; + } + + beforeEach(() => { + vi.useFakeTimers(); + ctx = createMockAudioContext(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ----------------------------------------------------------------------- + // scheduleNext crossfade + // ----------------------------------------------------------------------- + + describe("scheduleNext – crossfade", () => { + it("applies fade-in curve on playChunk", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + + const buf = createMockBuffer(8); + player.playChunk(buf, ctx.currentTime, 0); + + // Gain node should have setValueCurveAtTime called for fade-in + const gain = ctx._gains[0]!; + expect(gain.gain.setValueCurveAtTime).toHaveBeenCalled(); + + player.dispose(); + }); + + it("skips fade when crossfadeSec is 0", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + const buf = createMockBuffer(8); + player.playChunk(buf, ctx.currentTime, 0); + + const gain = ctx._gains[0]!; + expect(gain.gain.setValueCurveAtTime).not.toHaveBeenCalled(); + + player.dispose(); + }); + + it("skips fade-in when skipFadeIn=true", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + + const buf = createMockBuffer(8); + player.playChunk(buf, ctx.currentTime, 0, true); + + const gain = ctx._gains[0]!; + expect(gain.gain.setValueCurveAtTime).not.toHaveBeenCalled(); + + player.dispose(); + }); + + it("applies fade-in by default (skipFadeIn not set)", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + + const buf = createMockBuffer(8); + player.playChunk(buf, ctx.currentTime, 0); + + const gain = ctx._gains[0]!; + expect(gain.gain.setValueCurveAtTime).toHaveBeenCalled(); + + player.dispose(); + }); + + it("applies crossfade curves on scheduleNext", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + + player.playChunk(buf1, ctx.currentTime, 0); + + // scheduleNext at startTime = 8 + player.scheduleNext(buf2, 8); + + // Current gain should have fade-out, next gain should have fade-in + const currentGain = ctx._gains[0]!; + const nextGain = ctx._gains[1]!; + + // setValueCurveAtTime called at least twice (fade-in on play, fade-out on scheduleNext) + expect(currentGain.gain.setValueCurveAtTime.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(nextGain.gain.setValueCurveAtTime).toHaveBeenCalled(); + + player.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // startLookahead / stopLookahead + // ----------------------------------------------------------------------- + + describe("lookahead timer management", () => { + it("starts lookahead timer on playChunk", () => { + const onNeedNext = vi.fn(); + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + player.setOnNeedNext(onNeedNext); + + const buf = createMockBuffer(2); + player.playChunk(buf, ctx.currentTime, 0); + + // Advance time close to end + ctx._setCurrentTime(1.6); + vi.advanceTimersByTime(200); // LOOKAHEAD_INTERVAL_MS + + expect(onNeedNext).toHaveBeenCalled(); + + player.dispose(); + }); + + it("does not fire onNeedNext when plenty of time remaining", () => { + const onNeedNext = vi.fn(); + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + player.setOnNeedNext(onNeedNext); + + const buf = createMockBuffer(10); + player.playChunk(buf, ctx.currentTime, 0); + + // Only 1 sec elapsed, 9 sec remaining + ctx._setCurrentTime(1); + vi.advanceTimersByTime(200); + + expect(onNeedNext).not.toHaveBeenCalled(); + + player.dispose(); + }); + + it("stops lookahead on pause", () => { + const onNeedNext = vi.fn(); + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + player.setOnNeedNext(onNeedNext); + + const buf = createMockBuffer(2); + player.playChunk(buf, ctx.currentTime, 0); + player.pause(); + + ctx._setCurrentTime(1.6); + vi.advanceTimersByTime(200); + + expect(onNeedNext).not.toHaveBeenCalled(); + + player.dispose(); + }); + + it("stops lookahead on stop", () => { + const onNeedNext = vi.fn(); + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + player.setOnNeedNext(onNeedNext); + + const buf = createMockBuffer(2); + player.playChunk(buf, ctx.currentTime, 0); + player.stop(); + + ctx._setCurrentTime(1.6); + vi.advanceTimersByTime(200); + + expect(onNeedNext).not.toHaveBeenCalled(); + + player.dispose(); + }); + + it("does not fire onNeedNext when next is already scheduled", () => { + const onNeedNext = vi.fn(); + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + player.setOnNeedNext(onNeedNext); + + const buf1 = createMockBuffer(2); + const buf2 = createMockBuffer(2); + player.playChunk(buf1, ctx.currentTime, 0); + player.scheduleNext(buf2, 2); + + ctx._setCurrentTime(1.6); + vi.advanceTimersByTime(200); + + // nextSource exists, so onNeedNext should not fire + expect(onNeedNext).not.toHaveBeenCalled(); + + player.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // connectToDestination with through array + // ----------------------------------------------------------------------- + + describe("connectToDestination with through", () => { + it("chains through nodes to destination", () => { + const through1 = { connect: vi.fn() } as unknown as AudioNode; + const through2 = { connect: vi.fn() } as unknown as AudioNode; + + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + through: [through1, through2], + crossfadeSec: 0, + }); + + const buf = createMockBuffer(5); + player.playChunk(buf, ctx.currentTime, 0); + + // gain -> through1 -> through2 -> destination + const gain = ctx._gains[0]!; + expect(gain.connect).toHaveBeenCalledWith(through1); + expect((through1 as any).connect).toHaveBeenCalledWith(through2); + expect((through2 as any).connect).toHaveBeenCalledWith(ctx.destination); + + player.dispose(); + }); + + it("connects directly to destination when through is empty", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + const buf = createMockBuffer(5); + player.playChunk(buf, ctx.currentTime, 0); + + const gain = ctx._gains[0]!; + expect(gain.connect).toHaveBeenCalledWith(ctx.destination); + + player.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // handleSeek + // ----------------------------------------------------------------------- + + describe("handleSeek", () => { + it("restarts playback at the given offset", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + const buf = createMockBuffer(10); + player.playChunk(buf, ctx.currentTime, 0); + + ctx._setCurrentTime(3); + player.handleSeek(buf, 5); + + // Position should be at the seek offset + expect(player.getCurrentPosition()).toBe(5); + + // A new source should have been created + expect(ctx._sources.length).toBe(2); + expect(ctx._sources[1]!.start).toHaveBeenCalledWith(0, 5); + + player.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // Transition timer and cancel competition + // ----------------------------------------------------------------------- + + describe("transition timer cancellation", () => { + it("cancels transition timer on pause", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + + player.playChunk(buf1, ctx.currentTime, 0); + player.scheduleNext(buf2, 8); + + // Pause should cancel the transition timer + player.pause(); + + // Advance past transition time + vi.advanceTimersByTime(10000); + + // No crash should occur + player.dispose(); + }); + + it("cancels transition timer on stop", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + + player.playChunk(buf1, ctx.currentTime, 0); + player.scheduleNext(buf2, 8); + + player.stop(); + + // Advance past transition time + vi.advanceTimersByTime(10000); + + player.dispose(); + }); + + it("cancels previous transition when new playChunk is called", () => { + const onTransition = vi.fn(); + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + player.setOnTransition(onTransition); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + const buf3 = createMockBuffer(8); + + player.playChunk(buf1, ctx.currentTime, 0); + player.scheduleNext(buf2, 8); + + // New playChunk should cancel the pending transition + player.playChunk(buf3, ctx.currentTime, 0); + + // Advance past old transition time + ctx._setCurrentTime(9); + vi.advanceTimersByTime(10000); + + // onTransition should NOT have fired for the old schedule + expect(onTransition).not.toHaveBeenCalled(); + + player.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // onended → transition (gapless path) + // ----------------------------------------------------------------------- + + describe("onended gapless transition", () => { + it("promotes next source on current source ended", () => { + const onTransition = vi.fn(); + const onChunkEnded = vi.fn(); + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + player.setOnTransition(onTransition); + player.setOnChunkEnded(onChunkEnded); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + + player.playChunk(buf1, ctx.currentTime, 0); + player.scheduleNext(buf2, 8); + + // Simulate current source ended (before transition timer) + const src0 = ctx._sources[0]!; + if (src0.onended) src0.onended(); + + expect(onTransition).toHaveBeenCalled(); + expect(onChunkEnded).not.toHaveBeenCalled(); // next was available + + player.dispose(); + }); + + it("fires onChunkEnded when no next source available", () => { + const onChunkEnded = vi.fn(); + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + player.setOnChunkEnded(onChunkEnded); + + const buf1 = createMockBuffer(8); + player.playChunk(buf1, ctx.currentTime, 0); + + // Simulate current source ended (no next source) + const src0 = ctx._sources[0]!; + if (src0.onended) src0.onended(); + + expect(onChunkEnded).toHaveBeenCalled(); + + player.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // hasNextScheduled + // ----------------------------------------------------------------------- + + describe("hasNextScheduled", () => { + it("returns false initially", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + const buf = createMockBuffer(8); + player.playChunk(buf, ctx.currentTime, 0); + + expect(player.hasNextScheduled()).toBe(false); + + player.dispose(); + }); + + it("returns true after scheduleNext", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + + player.playChunk(buf1, ctx.currentTime, 0); + player.scheduleNext(buf2, 8); + + expect(player.hasNextScheduled()).toBe(true); + + player.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // getCurrentPosition edge cases + // ----------------------------------------------------------------------- + + describe("getCurrentPosition edge cases", () => { + it("returns 0 when stopped", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + expect(player.getCurrentPosition()).toBe(0); + + player.dispose(); + }); + + it("tracks offset correctly with non-zero start offset", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + const buf = createMockBuffer(10); + player.playChunk(buf, ctx.currentTime, 3); + + // At t=0, position = 0 + 3 = 3 + expect(player.getCurrentPosition()).toBe(3); + + // At t=2, position = 2 + 3 = 5 + ctx._setCurrentTime(2); + expect(player.getCurrentPosition()).toBe(5); + + player.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // P-02: 連続 playChunk での AudioNode disconnect + // ----------------------------------------------------------------------- + + describe("P-02: consecutive playChunk disconnect", () => { + it("previous source is disconnected when new playChunk is called", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + + player.playChunk(buf1, ctx.currentTime, 0); + const src1 = ctx._sources[ctx._sources.length - 1]!; + const gain1 = ctx._gains[ctx._gains.length - 1]!; + + player.playChunk(buf2, ctx.currentTime, 0); + + // Previous source and gain should be disconnected + expect(src1.disconnect).toHaveBeenCalled(); + expect(gain1.disconnect).toHaveBeenCalled(); + expect(src1.onended).toBeNull(); + }); + + it("10 consecutive playChunk calls don't leak AudioNodes", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + for (let i = 0; i < 10; i++) { + const buf = createMockBuffer(8); + player.playChunk(buf, ctx.currentTime, 0); + } + + // All sources except the last should have been disconnected + for (let i = 0; i < ctx._sources.length - 1; i++) { + expect(ctx._sources[i]!.disconnect).toHaveBeenCalled(); + } + + // All gains except the last should have been disconnected + for (let i = 0; i < ctx._gains.length - 1; i++) { + expect(ctx._gains[i]!.disconnect).toHaveBeenCalled(); + } + + player.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // L-01: 100 チャンク遷移後の AudioNode 蓄積 + // ----------------------------------------------------------------------- + + describe("L-01: AudioNode cleanup after many transitions", () => { + it("100 transitions disconnect all old sources", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + const onTransition = vi.fn(); + player.setOnTransition(onTransition); + + // Play first chunk + const firstBuf = createMockBuffer(1); + player.playChunk(firstBuf, ctx.currentTime, 0); + + for (let i = 0; i < 100; i++) { + const nextBuf = createMockBuffer(1); + player.scheduleNext(nextBuf, ctx.currentTime + 1); + + // Trigger onended to cause transition + const src = ctx._sources[ctx._sources.length - 2]; // current source + if (src?.onended) src.onended(); + } + + // All sources except the last 1-2 should have been disconnected + const disconnectedCount = ctx._sources.filter( + (s) => s.disconnect.mock.calls.length > 0, + ).length; + expect(disconnectedCount).toBeGreaterThanOrEqual(99); + + player.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // dispose safety + // ----------------------------------------------------------------------- + + describe("dispose safety", () => { + it("double dispose is safe", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + const buf = createMockBuffer(8); + player.playChunk(buf, ctx.currentTime, 0); + + player.dispose(); + expect(() => player.dispose()).not.toThrow(); + }); + + it("scheduleNext after dispose is no-op", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + + player.playChunk(buf1, ctx.currentTime, 0); + player.dispose(); + + expect(() => player.scheduleNext(buf2, 8)).not.toThrow(); + }); + }); + + // ----------------------------------------------------------------------- + // P-04: crossfade 区間中の getCurrentPosition + // ----------------------------------------------------------------------- + + describe("P-04: crossfade 区間中の getCurrentPosition", () => { + it("returns old source position before doTransition, new source position after", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + const onTransition = vi.fn(); + player.setOnTransition(onTransition); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + + // Play chunk 1 at t=0 → playStartCtxTime=0, playStartOffset=0 + player.playChunk(buf1, ctx.currentTime, 0); + + // Advance time + ctx._setCurrentTime(7); + + // Schedule next at startTime=8 → nextStartCtxTime = 8 - 0.1 = 7.9 + player.scheduleNext(buf2, 8); + + // Set time to crossfade midpoint (between 7.9 and 8.0) + ctx._setCurrentTime(7.95); + + // Before doTransition: position is based on old source + // getCurrentPosition = 7.95 - 0 + 0 = 7.95 + expect(player.getCurrentPosition()).toBeCloseTo(7.95, 2); + + // Fire transition timer (was scheduled at t=7 with delay 1050ms) + vi.advanceTimersByTime(1050); + + // After doTransition: position is based on new source + // playStartCtxTime = 7.9, playStartOffset = 0 + // getCurrentPosition = 7.95 - 7.9 + 0 = 0.05 + expect(onTransition).toHaveBeenCalledTimes(1); + expect(player.getCurrentPosition()).toBeCloseTo(0.05, 2); + + player.dispose(); + }); + }); +}); diff --git a/tests/stretcher/chunk-player-null-buffer.test.ts b/tests/stretcher/chunk-player-null-buffer.test.ts new file mode 100644 index 0000000..9ac398e --- /dev/null +++ b/tests/stretcher/chunk-player-null-buffer.test.ts @@ -0,0 +1,274 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createChunkPlayer } from "../../src/stretcher/chunk-player"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type MockSource = { + buffer: unknown; + connect: ReturnType; + disconnect: ReturnType; + start: ReturnType; + stop: ReturnType; + onended: (() => void) | null; +}; + +let ctxTime: number; +let bufferSourceInstances: MockSource[]; + +function createMockAudioContext(): AudioContext { + ctxTime = 100; + bufferSourceInstances = []; + return { + get currentTime() { + return ctxTime; + }, + destination: {} as AudioDestinationNode, + createBufferSource: vi.fn(() => { + const src: MockSource = { + buffer: null, + connect: vi.fn(), + disconnect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + onended: null, + }; + bufferSourceInstances.push(src); + return src; + }), + createGain: vi.fn(() => ({ + gain: { + value: 1, + setValueAtTime: vi.fn(), + setValueCurveAtTime: vi.fn(), + }, + connect: vi.fn(), + disconnect: vi.fn(), + })), + } as unknown as AudioContext; +} + +function createMockBuffer(durationSec: number): AudioBuffer { + const length = Math.round(durationSec * 44100); + const data = new Float32Array(length); + return { + numberOfChannels: 1, + length, + sampleRate: 44100, + duration: durationSec, + getChannelData: () => data, + } as unknown as AudioBuffer; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("createChunkPlayer — handleCurrentSourceEnded null buffer guard", () => { + let ctx: AudioContext; + + beforeEach(() => { + vi.useFakeTimers(); + ctx = createMockAudioContext(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("nextSource.buffer が null → onChunkEnded が呼ばれ doTransition は実行されない", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + const onChunkEnded = vi.fn(); + const onTransition = vi.fn(); + player.setOnChunkEnded(onChunkEnded); + player.setOnTransition(onTransition); + + const buf = createMockBuffer(8); + const nextBuf = createMockBuffer(8); + + // Play first chunk + player.playChunk(buf, ctx.currentTime, 0); + + // Save current source's onended before scheduleNext overwrites it + // playChunk creates source at index 0 (+ gain) + // The current source is bufferSourceInstances[0] + const currentSourceOnended = bufferSourceInstances[0]!.onended; + + // Schedule next chunk + ctxTime = 107; + player.scheduleNext(nextBuf, 108); + + // The next source is the last created buffer source + const nextSourceInstance = bufferSourceInstances[bufferSourceInstances.length - 1]!; + + // Manually set nextSource.buffer to null to simulate race condition + nextSourceInstance.buffer = null; + + // Trigger current source's onended → handleCurrentSourceEnded + currentSourceOnended!(); + + expect(onChunkEnded).toHaveBeenCalledTimes(1); + expect(onTransition).not.toHaveBeenCalled(); + + player.dispose(); + }); + + it("nextSource.buffer が有効 → doTransition が呼ばれ遷移する(回帰テスト)", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + const onChunkEnded = vi.fn(); + const onTransition = vi.fn(); + player.setOnChunkEnded(onChunkEnded); + player.setOnTransition(onTransition); + + const buf = createMockBuffer(8); + const nextBuf = createMockBuffer(8); + + player.playChunk(buf, ctx.currentTime, 0); + const currentSourceOnended = bufferSourceInstances[0]!.onended; + + ctxTime = 107; + player.scheduleNext(nextBuf, 108); + + // buffer is valid (not null) — normal gapless transition + currentSourceOnended!(); + + expect(onTransition).toHaveBeenCalledTimes(1); + expect(onChunkEnded).not.toHaveBeenCalled(); + + player.dispose(); + }); + + it("nextSource が存在しない → onChunkEnded が呼ばれる(回帰テスト)", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + const onChunkEnded = vi.fn(); + const onTransition = vi.fn(); + player.setOnChunkEnded(onChunkEnded); + player.setOnTransition(onTransition); + + const buf = createMockBuffer(8); + player.playChunk(buf, ctx.currentTime, 0); + + // No scheduleNext → onended should call onChunkEnded + const currentSourceOnended = bufferSourceInstances[0]!.onended; + currentSourceOnended!(); + + expect(onChunkEnded).toHaveBeenCalledTimes(1); + expect(onTransition).not.toHaveBeenCalled(); + + player.dispose(); + }); + + it("disposed 状態では何も起きない", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + const onChunkEnded = vi.fn(); + const onTransition = vi.fn(); + player.setOnChunkEnded(onChunkEnded); + player.setOnTransition(onTransition); + + const buf = createMockBuffer(8); + const nextBuf = createMockBuffer(8); + + player.playChunk(buf, ctx.currentTime, 0); + const currentSourceOnended = bufferSourceInstances[0]!.onended; + + ctxTime = 107; + player.scheduleNext(nextBuf, 108); + + // Set buffer to null and save onended reference before dispose + bufferSourceInstances[bufferSourceInstances.length - 1]!.buffer = null; + const savedOnended = currentSourceOnended; + + player.dispose(); + + // Invoke saved onended after dispose + savedOnended!(); + + expect(onChunkEnded).not.toHaveBeenCalled(); + expect(onTransition).not.toHaveBeenCalled(); + }); + + it("paused 状態では何も起きない", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + const onChunkEnded = vi.fn(); + const onTransition = vi.fn(); + player.setOnChunkEnded(onChunkEnded); + player.setOnTransition(onTransition); + + const buf = createMockBuffer(8); + const nextBuf = createMockBuffer(8); + + player.playChunk(buf, ctx.currentTime, 0); + + // Save onended reference before scheduleNext + const savedOnended = bufferSourceInstances[0]!.onended; + + ctxTime = 107; + player.scheduleNext(nextBuf, 108); + + // Set buffer to null + bufferSourceInstances[bufferSourceInstances.length - 1]!.buffer = null; + + // Pause (which sets onended=null internally, but we have saved reference) + player.pause(); + + savedOnended!(); + + expect(onChunkEnded).not.toHaveBeenCalled(); + expect(onTransition).not.toHaveBeenCalled(); + }); + + it("stopped 状態では何も起きない", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0, + }); + + const onChunkEnded = vi.fn(); + const onTransition = vi.fn(); + player.setOnChunkEnded(onChunkEnded); + player.setOnTransition(onTransition); + + const buf = createMockBuffer(8); + const nextBuf = createMockBuffer(8); + + player.playChunk(buf, ctx.currentTime, 0); + + // Save onended reference before scheduleNext + const savedOnended = bufferSourceInstances[0]!.onended; + + ctxTime = 107; + player.scheduleNext(nextBuf, 108); + + // Set buffer to null + bufferSourceInstances[bufferSourceInstances.length - 1]!.buffer = null; + + // Stop (which sets onended=null internally, but we have saved reference) + player.stop(); + + savedOnended!(); + + expect(onChunkEnded).not.toHaveBeenCalled(); + expect(onTransition).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/stretcher/chunk-player-pause.test.ts b/tests/stretcher/chunk-player-pause.test.ts index f13d3d2..bb7dc60 100644 --- a/tests/stretcher/chunk-player-pause.test.ts +++ b/tests/stretcher/chunk-player-pause.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createChunkPlayer } from "../../src/stretcher/chunk-player"; describe("createChunkPlayer – pause/resume 位置", () => { @@ -96,6 +96,31 @@ describe("createChunkPlayer – pause/resume 位置", () => { player.dispose(); }); + it("pause → playChunk(skipFadeIn=true) で 2つ目の GainNode にフェードインが適用されない", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + + const buf = createMockBuffer(10); + + // 最初の playChunk はフェードインあり + player.playChunk(buf, ctx.currentTime, 0); + const gain1 = (ctx as any).createGain.mock.results[0].value; + expect(gain1.gain.setValueCurveAtTime).toHaveBeenCalled(); + + // pause + ctxTime = 105; + player.pause(); + + // resume 用の playChunk(skipFadeIn=true) + player.playChunk(buf, ctx.currentTime, 5, true); + const gain2 = (ctx as any).createGain.mock.results[1].value; + expect(gain2.gain.setValueCurveAtTime).not.toHaveBeenCalled(); + + player.dispose(); + }); + it("複数回 pause/resume で位置ドリフトが蓄積しない", () => { const player = createChunkPlayer(ctx, { destination: ctx.destination, diff --git a/tests/stretcher/chunk-player-race.test.ts b/tests/stretcher/chunk-player-race.test.ts new file mode 100644 index 0000000..a5e16e3 --- /dev/null +++ b/tests/stretcher/chunk-player-race.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createChunkPlayer } from "../../src/stretcher/chunk-player"; +import { + createMockAudioContext, + findActiveSource, + type MockAudioContext, +} from "../helpers/audio-mocks"; + +describe("createChunkPlayer – race conditions", () => { + let ctx: MockAudioContext; + + function createMockBuffer(durationSec: number): AudioBuffer { + const length = Math.round(durationSec * 44100); + const data = new Float32Array(length); + return { + numberOfChannels: 1, + length, + sampleRate: 44100, + duration: durationSec, + getChannelData: () => data, + } as unknown as AudioBuffer; + } + + beforeEach(() => { + vi.useFakeTimers(); + ctx = createMockAudioContext(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("scheduleNext → onended fires before timer → no double transition", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + const onTransition = vi.fn(); + player.setOnTransition(onTransition); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + + player.playChunk(buf1, ctx.currentTime, 0); + ctx._setCurrentTime(7); + player.scheduleNext(buf2, 8); + + // Simulate onended firing before the transition timer + const src = findActiveSource(ctx._sources); + src!.onended!(); + + // Transition should have been called once (from onended) + expect(onTransition).toHaveBeenCalledTimes(1); + + // Now advance time to fire the transition timer + vi.advanceTimersByTime(2000); + + // Should still be 1 — timer callback should see nextSource is null + expect(onTransition).toHaveBeenCalledTimes(1); + + player.dispose(); + }); + + it("scheduleNext → pause → timer fires → paused check prevents action", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + const onTransition = vi.fn(); + const onChunkEnded = vi.fn(); + player.setOnTransition(onTransition); + player.setOnChunkEnded(onChunkEnded); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + + player.playChunk(buf1, ctx.currentTime, 0); + ctx._setCurrentTime(7); + player.scheduleNext(buf2, 8); + + // Pause before timer fires — this cancels the transition timer + player.pause(); + + // Timer should have been cancelled by pause + vi.advanceTimersByTime(5000); + expect(onTransition).not.toHaveBeenCalled(); + + player.dispose(); + }); + + it("scheduleNext → stop → timer fires → stopped check prevents action", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + const onTransition = vi.fn(); + player.setOnTransition(onTransition); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + + player.playChunk(buf1, ctx.currentTime, 0); + ctx._setCurrentTime(7); + player.scheduleNext(buf2, 8); + + // Stop before timer fires — this cancels the transition timer + player.stop(); + + vi.advanceTimersByTime(5000); + expect(onTransition).not.toHaveBeenCalled(); + + player.dispose(); + }); + + it("consecutive scheduleNext calls cancel previous timer", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + const onTransition = vi.fn(); + player.setOnTransition(onTransition); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + const buf3 = createMockBuffer(8); + + player.playChunk(buf1, ctx.currentTime, 0); + + // First scheduleNext + ctx._setCurrentTime(6); + player.scheduleNext(buf2, 7.9); + + // Second scheduleNext should cancel the first timer + ctx._setCurrentTime(7); + player.scheduleNext(buf3, 8); + + // Advance past both timer delays + vi.advanceTimersByTime(5000); + + // Only the second scheduleNext's transition should fire + expect(onTransition).toHaveBeenCalledTimes(1); + + player.dispose(); + }); + + it("dispose after scheduleNext → timer fires → no crash", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + const onTransition = vi.fn(); + player.setOnTransition(onTransition); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + + player.playChunk(buf1, ctx.currentTime, 0); + ctx._setCurrentTime(7); + player.scheduleNext(buf2, 8); + + // Dispose immediately — should cancel the timer + player.dispose(); + + // Advancing shouldn't cause any errors + expect(() => vi.advanceTimersByTime(5000)).not.toThrow(); + expect(onTransition).not.toHaveBeenCalled(); + }); + + // ----------------------------------------------------------------------- + // P-01: pause 中の scheduleNext + // ----------------------------------------------------------------------- + + it("P-01: scheduleNext after pause is no-op (paused source is null)", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + const onTransition = vi.fn(); + player.setOnTransition(onTransition); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + + player.playChunk(buf1, ctx.currentTime, 0); + player.pause(); + + // scheduleNext after pause — currentSource is null (stopped by pause) + // This should not crash, and the next source should be set up + expect(() => player.scheduleNext(buf2, 8)).not.toThrow(); + + // Even if we advance timers, transition should not fire + // because pause clears the source references + vi.advanceTimersByTime(10000); + + player.dispose(); + }); + + it("onended after dispose → no crash", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + const onChunkEnded = vi.fn(); + player.setOnChunkEnded(onChunkEnded); + + const buf1 = createMockBuffer(8); + player.playChunk(buf1, ctx.currentTime, 0); + + // Grab the source before dispose clears it + const src = ctx._sources[ctx._sources.length - 1]!; + const onendedFn = src.onended; + + player.dispose(); + + // onended handler should have been nulled, but even if called + // it should not crash + if (onendedFn) { + expect(() => onendedFn()).not.toThrow(); + } + expect(onChunkEnded).not.toHaveBeenCalled(); + }); + + // ----------------------------------------------------------------------- + // P-03: doTransition 直後の pause + // ----------------------------------------------------------------------- + + describe("P-03: doTransition 直後の pause", () => { + it("pause after doTransition yields correct position from new source", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + const onTransition = vi.fn(); + player.setOnTransition(onTransition); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + + // Play chunk 1 at t=0 → playStartCtxTime=0, playStartOffset=0 + player.playChunk(buf1, ctx.currentTime, 0); + + // Advance to near end of chunk 1 + ctx._setCurrentTime(7); + + // Schedule next chunk at startTime=8 → nextStartCtxTime = 8 - 0.1 = 7.9 + player.scheduleNext(buf2, 8); + + // calcTransitionDelay(8, 7) = max(0, (8-7)*1000 + 50) = 1050ms + // Advance timers to fire the doTransition callback + vi.advanceTimersByTime(1050); + + expect(onTransition).toHaveBeenCalledTimes(1); + + // Set time to just after transition point + ctx._setCurrentTime(8.0); + + // After doTransition: playStartCtxTime=7.9, playStartOffset=0 + // getCurrentPosition = 8.0 - 7.9 + 0 = 0.1 + expect(player.getCurrentPosition()).toBeCloseTo(0.1, 2); + + // Pause immediately after doTransition + player.pause(); + + // Position should be preserved as pausedPosition + expect(player.getCurrentPosition()).toBeCloseTo(0.1, 2); + + player.dispose(); + }); + }); +}); diff --git a/tests/stretcher/chunk-player-throttle.test.ts b/tests/stretcher/chunk-player-throttle.test.ts new file mode 100644 index 0000000..6f67b7d --- /dev/null +++ b/tests/stretcher/chunk-player-throttle.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createChunkPlayer } from "../../src/stretcher/chunk-player"; +import { LOOKAHEAD_INTERVAL_MS, LOOKAHEAD_THRESHOLD_SEC } from "../../src/stretcher/constants"; +import { + createMockAudioContext, + findActiveSource, + type MockAudioContext, +} from "../helpers/audio-mocks"; + +describe("createChunkPlayer – background tab throttling", () => { + let ctx: MockAudioContext; + + function createMockBuffer(durationSec: number): AudioBuffer { + const length = Math.round(durationSec * 44100); + const data = new Float32Array(length); + return { + numberOfChannels: 1, + length, + sampleRate: 44100, + duration: durationSec, + getChannelData: () => data, + } as unknown as AudioBuffer; + } + + beforeEach(() => { + vi.useFakeTimers(); + ctx = createMockAudioContext(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("lookahead fires onNeedNext even when timer is throttled to 1000ms", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + const onNeedNext = vi.fn(); + player.setOnNeedNext(onNeedNext); + + const buf = createMockBuffer(8); + player.playChunk(buf, ctx.currentTime, 0); + + // Advance to within LOOKAHEAD_THRESHOLD_SEC of end + ctx._setCurrentTime(8 - LOOKAHEAD_THRESHOLD_SEC + 0.1); + + // Simulate throttled interval: advance by 1000ms (background tab minimum) + vi.advanceTimersByTime(1000); + + expect(onNeedNext).toHaveBeenCalled(); + + player.dispose(); + }); + + it("lookahead fires multiple times under throttling with wider threshold", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + const onNeedNext = vi.fn(); + player.setOnNeedNext(onNeedNext); + + const buf = createMockBuffer(10); + player.playChunk(buf, ctx.currentTime, 0); + + // Position: remaining = 3.0s (exactly at threshold boundary) + ctx._setCurrentTime(10 - LOOKAHEAD_THRESHOLD_SEC); + + // Each 1000ms tick in background should still fire onNeedNext + vi.advanceTimersByTime(1000); + const firstCallCount = onNeedNext.mock.calls.length; + expect(firstCallCount).toBeGreaterThan(0); + + // Advance more time — still within threshold, onNeedNext fires again + ctx._setCurrentTime(10 - LOOKAHEAD_THRESHOLD_SEC + 1); + vi.advanceTimersByTime(1000); + expect(onNeedNext.mock.calls.length).toBeGreaterThan(firstCallCount); + + player.dispose(); + }); + + it("onended fires before transition timer → doTransition via onended, no double transition", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + const onTransition = vi.fn(); + player.setOnTransition(onTransition); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + + player.playChunk(buf1, ctx.currentTime, 0); + ctx._setCurrentTime(7); + player.scheduleNext(buf2, 8); + + // Background tab: onended fires before the delayed timer + const src = findActiveSource(ctx._sources); + src!.onended!(); + + expect(onTransition).toHaveBeenCalledTimes(1); + + // Now simulate the severely delayed timer (background tab: 1000ms+ later) + vi.advanceTimersByTime(5000); + + // No double transition + expect(onTransition).toHaveBeenCalledTimes(1); + + player.dispose(); + }); + + it("full background cycle: throttled lookahead + onended fallback transition", () => { + const player = createChunkPlayer(ctx, { + destination: ctx.destination, + crossfadeSec: 0.1, + }); + const onNeedNext = vi.fn(); + const onTransition = vi.fn(); + const onChunkEnded = vi.fn(); + player.setOnNeedNext(onNeedNext); + player.setOnTransition(onTransition); + player.setOnChunkEnded(onChunkEnded); + + const buf1 = createMockBuffer(8); + const buf2 = createMockBuffer(8); + + // Start playing chunk 1 + player.playChunk(buf1, ctx.currentTime, 0); + + // Simulate background tab: time advances but only 1 timer tick per second + ctx._setCurrentTime(8 - LOOKAHEAD_THRESHOLD_SEC + 0.5); + vi.advanceTimersByTime(1000); + expect(onNeedNext).toHaveBeenCalled(); + + // Schedule next chunk (as engine would after onNeedNext) + ctx._setCurrentTime(7.5); + player.scheduleNext(buf2, 8); + + // onended fires (hardware clock) before the throttled timer + const src = findActiveSource(ctx._sources); + src!.onended!(); + + expect(onTransition).toHaveBeenCalledTimes(1); + + // Delayed timer doesn't cause double transition + vi.advanceTimersByTime(5000); + expect(onTransition).toHaveBeenCalledTimes(1); + + player.dispose(); + }); + + it("LOOKAHEAD_THRESHOLD_SEC is wide enough for background tab (>= 3s)", () => { + // Verify the constant is set to allow 2-3 checks at 1000ms throttle + expect(LOOKAHEAD_THRESHOLD_SEC).toBeGreaterThanOrEqual(3.0); + }); + + it("LOOKAHEAD_INTERVAL_MS is reasonable for foreground use", () => { + expect(LOOKAHEAD_INTERVAL_MS).toBeLessThanOrEqual(500); + }); +}); diff --git a/tests/stretcher/chunk-splitter.test.ts b/tests/stretcher/chunk-splitter.test.ts index ac3052e..592da65 100644 --- a/tests/stretcher/chunk-splitter.test.ts +++ b/tests/stretcher/chunk-splitter.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { - splitIntoChunks, getChunkIndexForSample, getChunkIndexForTime, + splitIntoChunks, } from "../../src/stretcher/chunk-splitter"; describe("splitIntoChunks", () => { @@ -68,10 +68,8 @@ describe("splitIntoChunks", () => { for (let i = 0; i < chunks.length - 1; i++) { const current = chunks[i]!; const next = chunks[i + 1]!; - const currentNominalEnd = - current.inputEndSample - current.overlapAfter; - const nextNominalStart = - next.inputStartSample + next.overlapBefore; + const currentNominalEnd = current.inputEndSample - current.overlapAfter; + const nextNominalStart = next.inputStartSample + next.overlapBefore; expect(currentNominalEnd).toBe(nextNominalStart); } @@ -105,9 +103,7 @@ describe("getChunkIndexForSample", () => { it("returns last chunk for a sample past the end", () => { const totalSamples = sampleRate * 90; const chunks = splitIntoChunks(totalSamples, sampleRate, 30, 0.2); - expect(getChunkIndexForSample(chunks, totalSamples + 1000)).toBe( - chunks.length - 1, - ); + expect(getChunkIndexForSample(chunks, totalSamples + 1000)).toBe(chunks.length - 1); }); it("returns correct chunk for a sample in the middle", () => { diff --git a/tests/stretcher/conversion-estimator.test.ts b/tests/stretcher/conversion-estimator.test.ts index 81105cf..343712b 100644 --- a/tests/stretcher/conversion-estimator.test.ts +++ b/tests/stretcher/conversion-estimator.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { createConversionEstimator } from "../../src/stretcher/conversion-estimator"; describe("createConversionEstimator", () => { diff --git a/tests/stretcher/conversion-scheduler-advanced.test.ts b/tests/stretcher/conversion-scheduler-advanced.test.ts new file mode 100644 index 0000000..e216a5b --- /dev/null +++ b/tests/stretcher/conversion-scheduler-advanced.test.ts @@ -0,0 +1,657 @@ +import { describe, expect, it, vi } from "vitest"; +import { createConversionScheduler } from "../../src/stretcher/conversion-scheduler"; +import type { ChunkInfo, WorkerManager } from "../../src/stretcher/types"; + +function makeChunks(count: number): ChunkInfo[] { + return Array.from({ length: count }, (_, i) => ({ + index: i, + state: "pending" as const, + inputStartSample: i * 44100 * 5, + inputEndSample: (i + 1) * 44100 * 5, + overlapBefore: i === 0 ? 0 : 8820, + overlapAfter: i === count - 1 ? 0 : 8820, + outputBuffer: null, + outputLength: 0, + priority: 0, + retryCount: 0, + })); +} + +function createMockWorkerManager(): WorkerManager & { + posted: Array<{ chunkIndex: number; tempo: number }>; + cancelFn: ReturnType; + cancelChunkFn: ReturnType; + simulateResult(chunkIndex: number): void; + setCapacity(cap: boolean): void; +} { + let busy = false; + let cap = true; + let currentIdx: number | null = null; + const posted: Array<{ chunkIndex: number; tempo: number }> = []; + const cancelFn = vi.fn(() => { + busy = false; + currentIdx = null; + }); + const cancelChunkFn = vi.fn((_chunkIndex: number) => { + busy = false; + currentIdx = null; + }); + + return { + posted, + cancelFn, + cancelChunkFn, + simulateResult(_chunkIndex: number) { + busy = false; + currentIdx = null; + }, + setCapacity(newCap: boolean) { + cap = newCap; + }, + postConvert(chunkIndex, _inputData, tempo, _sampleRate) { + busy = true; + currentIdx = chunkIndex; + posted.push({ chunkIndex, tempo }); + }, + cancelCurrent: cancelFn, + cancelChunk: cancelChunkFn, + isBusy: () => busy, + hasCapacity: () => cap && !busy, + getCurrentChunkIndex: () => currentIdx, + getLastPostTime: () => null, + getPostTimeForChunk: () => null, + terminate() { + busy = false; + currentIdx = null; + }, + }; +} + +describe("createConversionScheduler – advanced", () => { + it("skips ready chunks in the queue", () => { + const chunks = makeChunks(5); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0); + + scheduler.start(0); + + // Mark chunk 0 as ready via _handleResult + wm.simulateResult(0); + (scheduler as any)._handleResult(0, [new Float32Array(1024)], 1024); + expect(chunks[0]!.state).toBe("ready"); + + // The next dispatch should skip chunk 0 and dispatch chunk 1 + const latestPost = wm.posted[wm.posted.length - 1]!; + expect(latestPost.chunkIndex).toBe(1); + + scheduler.dispose(); + }); + + it("returns immediately when hasCapacity is false", () => { + const chunks = makeChunks(5); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0); + + // Fill capacity + wm.setCapacity(false); + + scheduler.start(0); + + // Nothing should be dispatched + expect(wm.posted).toHaveLength(0); + + scheduler.dispose(); + }); + + it("double tempo change → restorePreviousTempo uses first tempo's cache", () => { + const chunks = makeChunks(3); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0); + + scheduler.start(0); + + // Complete all chunks at tempo 1.0 + const buf = [new Float32Array(1024)]; + for (let i = 0; i < 3; i++) { + wm.simulateResult(i); + (scheduler as any)._handleResult(i, buf, 1024); + } + + // First tempo change (1.0 → 1.5) + scheduler.handleTempoChange(1.5); + + // Complete some chunks at tempo 1.5 + wm.simulateResult(0); + (scheduler as any)._handleResult(0, buf, 512); + + // Second tempo change (1.5 → 2.0) + // This overwrites the previous cache with tempo 1.5 data + scheduler.handleTempoChange(2.0); + + // Restore should go back to tempo 1.5 (not 1.0) + const restored = scheduler.restorePreviousTempo(); + expect(restored).toBe(true); + + // Chunk 0 was ready at tempo 1.5 → should be restored + expect(chunks[0]!.state).toBe("ready"); + + scheduler.dispose(); + }); + + it("restorePreviousTempo returns false when no cache exists", () => { + const chunks = makeChunks(3); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0); + + scheduler.start(0); + + const restored = scheduler.restorePreviousTempo(); + expect(restored).toBe(false); + + scheduler.dispose(); + }); + + it("dispose then dispatchNext → safe (no dispatch)", () => { + const chunks = makeChunks(5); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0); + + scheduler.start(0); + const initialCount = wm.posted.length; + + scheduler.dispose(); + + // Calling dispatchNext after dispose should be safe + expect(() => scheduler.dispatchNext()).not.toThrow(); + expect(wm.posted.length).toBe(initialCount); + }); + + it("evicted chunk re-entering active window resets retryCount", () => { + const chunks = makeChunks(20); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0, { + keepAheadChunks: 3, + keepBehindChunks: 2, + }); + + scheduler.start(0); + + // Manually set chunk 10 as evicted with retryCount > 0 + chunks[10]!.state = "evicted"; + chunks[10]!.retryCount = 2; + + // Move playhead near chunk 10 + wm.simulateResult(0); + scheduler.handleSeek(10); + + // Chunk 10 should be re-queued with reset retryCount + expect(chunks[10]!.state).not.toBe("evicted"); + expect(chunks[10]!.retryCount).toBe(0); + + scheduler.dispose(); + }); + + it("updatePriorities with cancelDistThreshold boundary", () => { + const chunks = makeChunks(15); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0, { + cancelDistanceThreshold: 3, + }); + + scheduler.start(0); + + // Mark chunk 0 as converting (simulating active conversion) + // Actually it should already be converting after start + + // Move playhead to 4 — chunk 0 is distance 4 > threshold 3 + wm.simulateResult(0); + chunks[0]!.state = "converting"; + scheduler.updatePriorities(4); + + expect(wm.cancelChunkFn).toHaveBeenCalledWith(0); + + // But chunk 2 at distance 2 should NOT be cancelled + chunks[2]!.state = "converting"; + wm.cancelChunkFn.mockClear(); + scheduler.updatePriorities(4); + + expect(wm.cancelChunkFn).not.toHaveBeenCalledWith(2); + + scheduler.dispose(); + }); + + it("onChunkFailed callback is called after max retries", () => { + const chunks = makeChunks(3); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + const onFailed = vi.fn(); + + const scheduler = createConversionScheduler( + chunks, + wm, + extractData, + 44100, + 1.0, + undefined, + undefined, + onFailed, + ); + + scheduler.start(0); + + // Simulate 3 failures (MAX_CHUNK_RETRIES = 3) + wm.simulateResult(0); + (scheduler as any)._handleError(0, "error"); + wm.simulateResult(0); + (scheduler as any)._handleError(0, "error"); + wm.simulateResult(0); + (scheduler as any)._handleError(0, "error"); + + expect(onFailed).toHaveBeenCalledWith(0, "error"); + expect(chunks[0]!.state).toBe("failed"); + + scheduler.dispose(); + }); + + it("handleResult skips stale result when chunk state is not converting", () => { + const chunks = makeChunks(5); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + const onReady = vi.fn(); + + const scheduler = createConversionScheduler( + chunks, + wm, + extractData, + 44100, + 1.0, + undefined, + onReady, + ); + + scheduler.start(0); + + // Complete chunk 0 at tempo 1.0 + wm.simulateResult(0); + (scheduler as any)._handleResult(0, [new Float32Array(1024)], 1024); + expect(chunks[0]!.state).toBe("ready"); + onReady.mockClear(); + + // Block re-dispatch so chunks stay "pending" after tempo change + wm.setCapacity(false); + + // Tempo change resets chunk 0 to "pending" (queued by updatePriorities) + scheduler.handleTempoChange(2.0); + // With no capacity, chunk stays in "queued" state (not re-dispatched) + expect(chunks[0]!.state).toBe("queued"); + + // Stale result arrives for chunk 0 (old tempo) — chunk is "queued", not "converting" + (scheduler as any)._handleResult(0, [new Float32Array(512)], 512); + + // Should NOT be set to "ready" + expect(chunks[0]!.state).not.toBe("ready"); + // onChunkReady should NOT be called + expect(onReady).not.toHaveBeenCalled(); + + scheduler.dispose(); + }); + + it("handleResult accepts result when chunk is in converting state", () => { + const chunks = makeChunks(3); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + const onReady = vi.fn(); + + const scheduler = createConversionScheduler( + chunks, + wm, + extractData, + 44100, + 1.0, + undefined, + onReady, + ); + + scheduler.start(0); + + // Chunk 0 should be in "converting" state after dispatch + expect(chunks[0]!.state).toBe("converting"); + + // Normal result arrives while chunk is "converting" + wm.simulateResult(0); + (scheduler as any)._handleResult(0, [new Float32Array(1024)], 1024); + + // Should be set to "ready" + expect(chunks[0]!.state).toBe("ready"); + expect(onReady).toHaveBeenCalledWith(0); + + scheduler.dispose(); + }); + + it("handleResult with invalid chunk index is safe", () => { + const chunks = makeChunks(3); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0); + + scheduler.start(0); + + // Pass an out-of-bounds index + expect(() => { + (scheduler as any)._handleResult(99, [new Float32Array(1024)], 1024); + }).not.toThrow(); + + scheduler.dispose(); + }); + + // ----------------------------------------------------------------------- + // CS-04: dispose 後の handleResult / handleError + // ----------------------------------------------------------------------- + + it("CS-04: handleResult after dispose does not call onChunkReady", () => { + const chunks = makeChunks(3); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + const onReady = vi.fn(); + + const scheduler = createConversionScheduler( + chunks, + wm, + extractData, + 44100, + 1.0, + undefined, + onReady, + ); + + scheduler.start(0); + expect(chunks[0]!.state).toBe("converting"); + + scheduler.dispose(); + + // Result arrives after dispose + expect(() => { + (scheduler as any)._handleResult(0, [new Float32Array(1024)], 1024); + }).not.toThrow(); + expect(onReady).not.toHaveBeenCalled(); + // chunk state should NOT be set to "ready" + expect(chunks[0]!.state).not.toBe("ready"); + }); + + it("CS-04: handleError after dispose does not call onChunkFailed", () => { + const chunks = makeChunks(3); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + const onFailed = vi.fn(); + + const scheduler = createConversionScheduler( + chunks, + wm, + extractData, + 44100, + 1.0, + undefined, + undefined, + onFailed, + ); + + scheduler.start(0); + // Fail chunk 0 twice first to get close to max retries + wm.simulateResult(0); + (scheduler as any)._handleError(0, "error"); + wm.simulateResult(0); + (scheduler as any)._handleError(0, "error"); + + scheduler.dispose(); + + // Third error after dispose — would trigger onChunkFailed without guard + expect(() => { + (scheduler as any)._handleError(0, "error"); + }).not.toThrow(); + expect(onFailed).not.toHaveBeenCalled(); + }); + + // ----------------------------------------------------------------------- + // CS-01: handleResult when chunk state is "failed" + // ----------------------------------------------------------------------- + + it("CS-01: handleResult skips chunk in failed state", () => { + const chunks = makeChunks(3); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + const onReady = vi.fn(); + + const scheduler = createConversionScheduler( + chunks, + wm, + extractData, + 44100, + 1.0, + undefined, + onReady, + ); + + scheduler.start(0); + // Manually set chunk 0 to "failed" (simulating max retries reached) + chunks[0]!.state = "failed"; + + (scheduler as any)._handleResult(0, [new Float32Array(1024)], 1024); + + // Should not become "ready" + expect(chunks[0]!.state).not.toBe("ready"); + expect(onReady).not.toHaveBeenCalled(); + + scheduler.dispose(); + }); + + // ----------------------------------------------------------------------- + // CS-02: handleResult when chunk state is "evicted" + // ----------------------------------------------------------------------- + + it("CS-02: handleResult skips chunk in evicted state", () => { + const chunks = makeChunks(3); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + const onReady = vi.fn(); + + const scheduler = createConversionScheduler( + chunks, + wm, + extractData, + 44100, + 1.0, + undefined, + onReady, + ); + + scheduler.start(0); + // Manually set chunk 0 to "evicted" + chunks[0]!.state = "evicted"; + + (scheduler as any)._handleResult(0, [new Float32Array(1024)], 1024); + + // Should not become "ready" + expect(chunks[0]!.state).not.toBe("ready"); + expect(onReady).not.toHaveBeenCalled(); + + scheduler.dispose(); + }); + + // ----------------------------------------------------------------------- + // L-02: dispose 後の previousTempoCache + // ----------------------------------------------------------------------- + + it("L-02: dispose clears queue and subsequent restorePreviousTempo is safe", () => { + const chunks = makeChunks(3); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0); + + scheduler.start(0); + + // Complete chunks + const buf = [new Float32Array(1024)]; + for (let i = 0; i < 3; i++) { + wm.simulateResult(i); + (scheduler as any)._handleResult(i, buf, 1024); + } + + // Tempo change to create cache + scheduler.handleTempoChange(2.0); + + scheduler.dispose(); + + // Operations after dispose should not throw + expect(() => scheduler.restorePreviousTempo()).not.toThrow(); + expect(() => scheduler.dispatchNext()).not.toThrow(); + }); + + // ----------------------------------------------------------------------- + // CS-03: seek と setTempo の連続実行 + // ----------------------------------------------------------------------- + + describe("CS-03: seek と setTempo の連続実行", () => { + it("CS-03a: handleSeek → handleTempoChange uses seek's currentChunkIdx", () => { + const chunks = makeChunks(5); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0); + scheduler.start(0); + + // Complete all chunks at tempo 1.0 + const buf = [new Float32Array(1024)]; + for (let i = 0; i < 5; i++) { + wm.simulateResult(i); + (scheduler as any)._handleResult(i, buf, 1024); + } + + wm.cancelFn.mockClear(); + wm.posted.length = 0; + + // Seek to index 3 → currentChunkIdx becomes 3 + scheduler.handleSeek(3); + + // Immediately set tempo to 1.5 + scheduler.handleTempoChange(1.5); + + // cancelCurrent should have been called by handleTempoChange + expect(wm.cancelFn).toHaveBeenCalled(); + + // All chunks should be reset (not ready) + for (const chunk of chunks) { + expect(chunk.state).not.toBe("ready"); + } + + // First dispatch after tempo change should be chunk 3 (seek position) + const tempoPostings = wm.posted.filter((p) => p.tempo === 1.5); + expect(tempoPostings.length).toBeGreaterThan(0); + expect(tempoPostings[0]!.chunkIndex).toBe(3); + + scheduler.dispose(); + }); + + it("CS-03b: handleTempoChange → handleSeek dispatches from seek target", () => { + const chunks = makeChunks(10); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0); + scheduler.start(0); + + // Complete all chunks at tempo 1.0 + const buf = [new Float32Array(1024)]; + for (let i = 0; i < 10; i++) { + wm.simulateResult(i); + (scheduler as any)._handleResult(i, buf, 1024); + } + + // Tempo change resets all chunks, dispatches chunk 0 + scheduler.handleTempoChange(1.5); + + // Free worker capacity (chunk 0 was dispatched) + wm.simulateResult(0); + wm.posted.length = 0; + + // Seek to index 3 — re-prioritize around index 3 + scheduler.handleSeek(3); + + // First dispatch should be chunk 3 (distance 0 from playhead) + expect(wm.posted.length).toBeGreaterThan(0); + expect(wm.posted[0]!.chunkIndex).toBe(3); + + scheduler.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // CS-05: restorePreviousTempo → handleTempoChange の連鎖 + // ----------------------------------------------------------------------- + + describe("CS-05: restorePreviousTempo → handleTempoChange の連鎖", () => { + it("second handleTempoChange caches restored state, second restore works", () => { + const chunks = makeChunks(3); + const wm = createMockWorkerManager(); + const extractData = vi.fn(() => [new Float32Array(1024)]); + + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0); + scheduler.start(0); + + // Complete all chunks at tempo 1.0 + const buf = [new Float32Array(1024)]; + for (let i = 0; i < 3; i++) { + wm.simulateResult(i); + (scheduler as any)._handleResult(i, buf, 1024); + } + + // All chunks should be ready + expect(chunks.every((c) => c.state === "ready")).toBe(true); + + // 1st tempo change: 1.0 → 1.5 (caches tempo=1.0 buffers) + scheduler.handleTempoChange(1.5); + expect(chunks.every((c) => c.state !== "ready")).toBe(true); + + // Restore: back to tempo 1.0, chunks restored + const restored1 = scheduler.restorePreviousTempo(); + expect(restored1).toBe(true); + for (const chunk of chunks) { + expect(chunk.state).toBe("ready"); + expect(chunk.outputBuffer).not.toBeNull(); + } + + // 2nd tempo change: 1.0 → 2.0 (caches the restored state) + scheduler.handleTempoChange(2.0); + expect(chunks.every((c) => c.state !== "ready")).toBe(true); + + // 2nd restore: back to tempo 1.0, chunks restored again + const restored2 = scheduler.restorePreviousTempo(); + expect(restored2).toBe(true); + for (const chunk of chunks) { + expect(chunk.state).toBe("ready"); + expect(chunk.outputBuffer).not.toBeNull(); + } + + // 3rd restore: no cache → returns false + const restored3 = scheduler.restorePreviousTempo(); + expect(restored3).toBe(false); + + scheduler.dispose(); + }); + }); +}); diff --git a/tests/stretcher/conversion-scheduler.test.ts b/tests/stretcher/conversion-scheduler.test.ts index 3b550bc..1c9c573 100644 --- a/tests/stretcher/conversion-scheduler.test.ts +++ b/tests/stretcher/conversion-scheduler.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createConversionScheduler } from "../../src/stretcher/conversion-scheduler"; import type { ChunkInfo, WorkerManager } from "../../src/stretcher/types"; @@ -68,13 +68,7 @@ describe("createConversionScheduler", () => { const wm = createMockWorkerManager(); const extractData = vi.fn(() => [new Float32Array(1024)]); - const scheduler = createConversionScheduler( - chunks, - wm, - extractData, - 44100, - 1.5, - ); + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.5); scheduler.start(0); @@ -90,13 +84,7 @@ describe("createConversionScheduler", () => { const wm = createMockWorkerManager(); const extractData = vi.fn(() => [new Float32Array(1024)]); - const scheduler = createConversionScheduler( - chunks, - wm, - extractData, - 44100, - 1.0, - ); + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0); scheduler.start(0); @@ -122,14 +110,9 @@ describe("createConversionScheduler", () => { const wm = createMockWorkerManager(); const extractData = vi.fn(() => [new Float32Array(1024)]); - const scheduler = createConversionScheduler( - chunks, - wm, - extractData, - 44100, - 1.0, - { cancelDistanceThreshold: 2 }, - ); + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0, { + cancelDistanceThreshold: 2, + }); scheduler.start(0); expect(wm.isBusy()).toBe(true); @@ -146,13 +129,7 @@ describe("createConversionScheduler", () => { const wm = createMockWorkerManager(); const extractData = vi.fn(() => [new Float32Array(1024)]); - const scheduler = createConversionScheduler( - chunks, - wm, - extractData, - 44100, - 1.0, - ); + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0); scheduler.start(0); @@ -166,7 +143,9 @@ describe("createConversionScheduler", () => { // All chunks should be reset for (const chunk of chunks) { - expect(chunk.state === "pending" || chunk.state === "queued" || chunk.state === "converting").toBe(true); + expect( + chunk.state === "pending" || chunk.state === "queued" || chunk.state === "converting", + ).toBe(true); } scheduler.dispose(); @@ -177,13 +156,7 @@ describe("createConversionScheduler", () => { const wm = createMockWorkerManager(); const extractData = vi.fn(() => [new Float32Array(1024)]); - const scheduler = createConversionScheduler( - chunks, - wm, - extractData, - 44100, - 1.0, - ); + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0); scheduler.start(0); @@ -217,14 +190,10 @@ describe("createConversionScheduler", () => { const wm = createMockWorkerManager(); const extractData = vi.fn(() => [new Float32Array(1024)]); - const scheduler = createConversionScheduler( - chunks, - wm, - extractData, - 44100, - 1.0, - { keepAheadChunks: 5, keepBehindChunks: 3 }, - ); + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0, { + keepAheadChunks: 5, + keepBehindChunks: 3, + }); scheduler.start(0); @@ -247,14 +216,10 @@ describe("createConversionScheduler", () => { const wm = createMockWorkerManager(); const extractData = vi.fn(() => [new Float32Array(1024)]); - const scheduler = createConversionScheduler( - chunks, - wm, - extractData, - 44100, - 1.0, - { keepAheadChunks: 5, keepBehindChunks: 3 }, - ); + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0, { + keepAheadChunks: 5, + keepBehindChunks: 3, + }); scheduler.start(10); @@ -289,14 +254,10 @@ describe("createConversionScheduler", () => { const wm = createMockWorkerManager(); const extractData = vi.fn(() => [new Float32Array(1024)]); - const scheduler = createConversionScheduler( - chunks, - wm, - extractData, - 44100, - 1.0, - { keepAheadChunks: 3, keepBehindChunks: 2 }, - ); + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0, { + keepAheadChunks: 3, + keepBehindChunks: 2, + }); scheduler.start(5); @@ -332,14 +293,10 @@ describe("createConversionScheduler", () => { const wm = createMockWorkerManager(); const extractData = vi.fn(() => [new Float32Array(1024)]); - const scheduler = createConversionScheduler( - chunks, - wm, - extractData, - 44100, - 1.0, - { keepAheadChunks: 3, keepBehindChunks: 2 }, - ); + const scheduler = createConversionScheduler(chunks, wm, extractData, 44100, 1.0, { + keepAheadChunks: 3, + keepBehindChunks: 2, + }); scheduler.start(5); diff --git a/tests/stretcher/engine-advanced.test.ts b/tests/stretcher/engine-advanced.test.ts new file mode 100644 index 0000000..ac8f9a0 --- /dev/null +++ b/tests/stretcher/engine-advanced.test.ts @@ -0,0 +1,1657 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createMockAudioBuffer, + createMockAudioContext, + findActiveSource, + type MockAudioContext, + stubWorkerGlobals, +} from "../helpers/audio-mocks"; + +// --------------------------------------------------------------------------- +// Setup Worker stubs +// --------------------------------------------------------------------------- + +const workerStubs = stubWorkerGlobals(); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// 24s audio, sampleRate=44100 → 3 chunks (CHUNK_DURATION_SEC=8) +const CHUNK0_RAW = 361620; +const CHUNK1_RAW = 370440; +const CHUNK2_RAW = 361620; + +function createEngine( + ctx: MockAudioContext, + buffer: AudioBuffer, + opts?: Partial<{ tempo: number; loop: boolean; offset: number }>, +) { + return createStretcherEngine(ctx, buffer, { + tempo: opts?.tempo ?? 1.0, + loop: opts?.loop ?? false, + offset: opts?.offset ?? 0, + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +let createStretcherEngine: typeof import("../../src/stretcher/engine")["createStretcherEngine"]; + +describe("engine – advanced paths", () => { + let ctx: MockAudioContext; + let buffer: AudioBuffer; + + beforeEach(async () => { + vi.useFakeTimers(); + workerStubs.workers.length = 0; + vi.clearAllMocks(); + const mod = await import("../../src/stretcher/engine"); + createStretcherEngine = mod.createStretcherEngine; + ctx = createMockAudioContext(); + buffer = createMockAudioBuffer(24); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ----------------------------------------------------------------------- + // advanceToNextChunk loop処理 + // ----------------------------------------------------------------------- + + describe("advanceToNextChunk – loop handling", () => { + it("loops back to chunk 0 when reaching end with loop enabled", () => { + const engine = createEngine(ctx, buffer, { loop: true }); + const loopHandler = vi.fn(); + engine.on("loop", loopHandler); + + engine.start(); + + // Make all chunks ready + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + // Advance through all chunks via onended + const src0 = findActiveSource(ctx._sources); + src0!.onended!(); + const src1 = findActiveSource(ctx._sources); + src1!.onended!(); + + // Now at chunk 2, trigger onended → should loop to chunk 0 + const src2 = findActiveSource(ctx._sources); + src2!.onended!(); + + expect(loopHandler).toHaveBeenCalledTimes(1); + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + + it("ends playback when reaching end without loop", () => { + const engine = createEngine(ctx, buffer, { loop: false }); + const endedHandler = vi.fn(); + engine.on("ended", endedHandler); + + engine.start(); + + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + + // Advance through all chunks + const src0 = findActiveSource(ctx._sources); + src0!.onended!(); + const src1 = findActiveSource(ctx._sources); + src1!.onended!(); + const src2 = findActiveSource(ctx._sources); + src2!.onended!(); + + expect(endedHandler).toHaveBeenCalledTimes(1); + expect(engine.getStatus().phase).toBe("ended"); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // exitBuffering – bufferingResumePosition path + // ----------------------------------------------------------------------- + + describe("exitBuffering – bufferingResumePosition", () => { + it("resumes from buffering with correct position after seek", () => { + const engine = createEngine(ctx, buffer, { offset: 0 }); + const bufferedHandler = vi.fn(); + engine.on("buffered", bufferedHandler); + + engine.start(); + + // Make chunk 0 ready to start playing + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + // Seek to a position that needs buffering + // Chunk 2 is not ready, so seek to 16s should enter buffering + engine.seek(16); + + // Chunk 2 needs to be ready + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + + expect(bufferedHandler).toHaveBeenCalled(); + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // exitBuffering – advanceToNextChunk recursive call + // ----------------------------------------------------------------------- + + describe("exitBuffering – advanceToNextChunk recursion", () => { + it("advances to next chunk when offset exceeds output duration", () => { + // This tests the case where bufferingResumePosition is near the end + // of a chunk's output, triggering advanceToNextChunk from exitBuffering + const engine = createEngine(ctx, buffer, { offset: 0 }); + + engine.start(); + + // Make chunks ready + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // seek when chunk is ready – offset calculation + // ----------------------------------------------------------------------- + + describe("seek – offset calculation", () => { + it("seeks to correct position within a ready chunk", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + // Seek to 4 sec (within chunk 0) + engine.seek(4); + + // Position should be approximately 4 + const pos = engine.getCurrentPosition(); + expect(pos).toBeGreaterThanOrEqual(3.5); + expect(pos).toBeLessThanOrEqual(4.5); + + engine.dispose(); + }); + + it("seeks to position in a different chunk", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + // Seek to 10 sec (chunk 1) + engine.seek(10); + + const pos = engine.getCurrentPosition(); + expect(pos).toBeGreaterThanOrEqual(9.5); + expect(pos).toBeLessThanOrEqual(10.5); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // onChunkReady proactive scheduleNext + // ----------------------------------------------------------------------- + + describe("onChunkReady – proactive scheduleNext", () => { + it("schedules next chunk when it becomes ready during playing", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + + // Only chunk 0 ready first + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + // Need another chunk ready to exit buffering + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + // Advance time close to end of chunk 0 + ctx._setCurrentTime(7.5); + + // Now chunk 1 is already ready, trigger lookahead + vi.advanceTimersByTime(200); // LOOKAHEAD_INTERVAL_MS + + const createBufferMock = ctx.createBuffer as ReturnType; + const callCount = createBufferMock.mock.calls.length; + expect(callCount).toBeGreaterThanOrEqual(2); // at least chunk 0 + chunk 1 buffers + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // Potential bug: setTempo 後の古い Worker result 適用 + // ----------------------------------------------------------------------- + + describe("setTempo – stale worker result", () => { + it("tempo change resets chunks and re-queues conversion", () => { + const engine = createEngine(ctx, buffer, { tempo: 1.0 }); + + engine.start(); + + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + // Change tempo + engine.setTempo(2.0); + + // Engine should enter buffering + expect(engine.getStatus().phase).toBe("buffering"); + + // The chunks should be re-queued for conversion at new tempo + const status = engine.getStatus(); + expect(status.playback.tempo).toBe(2.0); + + engine.dispose(); + }); + + it("result arriving after tempo change is handled", () => { + const engine = createEngine(ctx, buffer, { tempo: 1.0 }); + + engine.start(); + + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + // Change tempo – chunks get reset + engine.setTempo(2.0); + expect(engine.getStatus().phase).toBe("buffering"); + + // Wait for debounced handleTempoChange to fire + vi.advanceTimersByTime(100); + + // New worker results arrive at new tempo + workerStubs.simulateWorkerResult(0, 0, Math.round(CHUNK0_RAW / 2)); + workerStubs.simulateWorkerResult(1, 1, Math.round(CHUNK1_RAW / 2)); + + // Should exit buffering + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // setTempo – debounce + // ----------------------------------------------------------------------- + + describe("setTempo – debounce", () => { + it("rapid setTempo calls trigger buffering only once", () => { + const engine = createEngine(ctx, buffer, { tempo: 1.0 }); + const bufferingHandler = vi.fn(); + engine.on("buffering", bufferingHandler); + + engine.start(); + + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + bufferingHandler.mockClear(); + + // Rapid tempo changes + engine.setTempo(1.2); + engine.setTempo(1.5); + engine.setTempo(1.8); + engine.setTempo(2.0); + engine.setTempo(2.5); + + // Only 1 buffering event (from the first call in the burst) + const tempoChangeBufferings = bufferingHandler.mock.calls.filter( + (call: any) => call[0].reason === "tempo-change", + ); + expect(tempoChangeBufferings).toHaveLength(1); + + engine.dispose(); + }); + + it("debounced setTempo applies final tempo to scheduler", () => { + const engine = createEngine(ctx, buffer, { tempo: 1.0 }); + + engine.start(); + + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + // Rapid tempo changes + engine.setTempo(1.5); + engine.setTempo(2.0); + engine.setTempo(2.5); + + // Tempo should be updated immediately (for getStatus) + expect(engine.getStatus().playback.tempo).toBe(2.5); + + // Fire the debounce timer + vi.advanceTimersByTime(100); + + // After debounce, workers should be re-dispatched + // Simulate results at final tempo + workerStubs.simulateWorkerResult(0, 0, Math.round(CHUNK0_RAW / 2.5)); + workerStubs.simulateWorkerResult(1, 1, Math.round(CHUNK1_RAW / 2.5)); + + // Should exit buffering and play at new tempo + expect(engine.getStatus().phase).toBe("playing"); + expect(engine.getStatus().playback.tempo).toBe(2.5); + + engine.dispose(); + }); + + it("single setTempo still triggers buffering immediately", () => { + const engine = createEngine(ctx, buffer, { tempo: 1.0 }); + const bufferingHandler = vi.fn(); + engine.on("buffering", bufferingHandler); + + engine.start(); + + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + bufferingHandler.mockClear(); + + // Single setTempo + engine.setTempo(2.0); + + // Buffering should fire immediately + expect(bufferingHandler).toHaveBeenCalledWith({ reason: "tempo-change" }); + expect(engine.getStatus().phase).toBe("buffering"); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // setTempo – during pause + // ----------------------------------------------------------------------- + + describe("setTempo – during pause", () => { + it("does not resume playback when setTempo is called during pause", () => { + const engine = createEngine(ctx, buffer, { tempo: 1.0 }); + const bufferingHandler = vi.fn(); + engine.on("buffering", bufferingHandler); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + engine.pause(); + expect(engine.getStatus().phase).toBe("paused"); + bufferingHandler.mockClear(); + + engine.setTempo(2.0); + + expect(engine.getStatus().phase).toBe("paused"); + expect(bufferingHandler).not.toHaveBeenCalled(); + expect(engine.getStatus().playback.tempo).toBe(2.0); + + engine.dispose(); + }); + + it("resume after pause+setTempo triggers tempo-change buffering", () => { + const engine = createEngine(ctx, buffer, { tempo: 1.0 }); + const bufferingHandler = vi.fn(); + engine.on("buffering", bufferingHandler); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + engine.pause(); + engine.setTempo(2.0); + bufferingHandler.mockClear(); + + engine.resume(); + + expect(engine.getStatus().phase).toBe("buffering"); + expect(bufferingHandler).toHaveBeenCalledWith({ reason: "tempo-change" }); + + // チャンク変換完了後に再生再開 + workerStubs.simulateWorkerResult(0, 0, Math.round(CHUNK0_RAW / 2)); + workerStubs.simulateWorkerResult(1, 1, Math.round(CHUNK1_RAW / 2)); + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + + it("multiple setTempo during pause uses final tempo on resume", () => { + const engine = createEngine(ctx, buffer, { tempo: 1.0 }); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + engine.pause(); + engine.setTempo(1.5); + engine.setTempo(2.0); + engine.setTempo(2.5); + + expect(engine.getStatus().playback.tempo).toBe(2.5); + expect(engine.getStatus().phase).toBe("paused"); + + engine.resume(); + expect(engine.getStatus().phase).toBe("buffering"); + + engine.dispose(); + }); + + it("pause → setTempo → getCurrentPosition does not shift", () => { + const engine = createEngine(ctx, buffer, { tempo: 1.0 }); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + const posBeforePause = engine.getCurrentPosition(); + engine.pause(); + const posAfterPause = engine.getCurrentPosition(); + + engine.setTempo(2.0); + const posAfterSetTempo = engine.getCurrentPosition(); + + // Position should not shift after setTempo during pause + expect(posAfterSetTempo).toBeCloseTo(posAfterPause, 3); + expect(posAfterSetTempo).toBeCloseTo(posBeforePause, 3); + + engine.dispose(); + }); + + it("pause → multiple setTempo → getCurrentPosition stays stable", () => { + const engine = createEngine(ctx, buffer, { tempo: 1.0 }); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + engine.pause(); + const posAfterPause = engine.getCurrentPosition(); + + engine.setTempo(1.5); + expect(engine.getCurrentPosition()).toBeCloseTo(posAfterPause, 3); + + engine.setTempo(2.0); + expect(engine.getCurrentPosition()).toBeCloseTo(posAfterPause, 3); + + engine.setTempo(3.0); + expect(engine.getCurrentPosition()).toBeCloseTo(posAfterPause, 3); + + engine.dispose(); + }); + + it("pause → setTempo → resume resumes from correct position", () => { + const engine = createEngine(ctx, buffer, { tempo: 1.0 }); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + const posBeforePause = engine.getCurrentPosition(); + engine.pause(); + + engine.setTempo(2.0); + engine.resume(); + + // Should enter buffering for tempo-change + expect(engine.getStatus().phase).toBe("buffering"); + + // Provide new chunks at new tempo + workerStubs.simulateWorkerResult(0, 0, Math.round(CHUNK0_RAW / 2)); + workerStubs.simulateWorkerResult(1, 1, Math.round(CHUNK1_RAW / 2)); + + expect(engine.getStatus().phase).toBe("playing"); + // Position after resume should be close to the position before pause + const posAfterResume = engine.getCurrentPosition(); + expect(posAfterResume).toBeCloseTo(posBeforePause, 1); + + engine.dispose(); + }); + + it("setTempo to same value during pause does not trigger buffering on resume", () => { + const engine = createEngine(ctx, buffer, { tempo: 1.0 }); + const bufferingHandler = vi.fn(); + engine.on("buffering", bufferingHandler); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + engine.pause(); + bufferingHandler.mockClear(); + + engine.setTempo(1.0); // 同じテンポ → early return (newTempo === currentTempo) + + engine.resume(); + // 通常の resume → playing (buffering ではない) + expect(engine.getStatus().phase).toBe("playing"); + const tempoChanges = bufferingHandler.mock.calls.filter( + (call: any) => call[0].reason === "tempo-change", + ); + expect(tempoChanges).toHaveLength(0); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // getStatus / getSnapshot + // ----------------------------------------------------------------------- + + describe("getStatus / getSnapshot", () => { + it("returns correct status before start", () => { + const engine = createEngine(ctx, buffer); + const status = engine.getStatus(); + expect(status.phase).toBe("waiting"); + expect(status.conversion.total).toBe(3); + expect(status.conversion.ready).toBe(0); + expect(status.playback.duration).toBe(24); + engine.dispose(); + }); + + it("returns correct snapshot during playback", () => { + const engine = createEngine(ctx, buffer); + engine.start(); + + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + const snapshot = engine.getSnapshot(); + expect(snapshot.totalChunks).toBe(3); + expect(snapshot.currentChunkIndex).toBe(0); + expect(snapshot.tempo).toBe(1); + expect(snapshot.buffering).toBe(false); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // pause / resume + // ----------------------------------------------------------------------- + + describe("pause / resume", () => { + it("pauses and resumes correctly", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + engine.pause(); + expect(engine.getStatus().phase).toBe("paused"); + + engine.resume(); + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + + it("resume enters buffering if current chunk not ready", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + // Advance to chunk 2 (not ready) + const src0 = findActiveSource(ctx._sources); + src0!.onended!(); + const src1 = findActiveSource(ctx._sources); + src1!.onended!(); + + // Now at chunk 2, which enters buffering since it's not ready + expect(engine.getStatus().phase).toBe("buffering"); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // stop + // ----------------------------------------------------------------------- + + describe("stop", () => { + it("stops the engine", () => { + const engine = createEngine(ctx, buffer); + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + engine.stop(); + expect(engine.getStatus().phase).toBe("ended"); + + engine.dispose(); + }); + + it("stop is safe when already ended", () => { + const engine = createEngine(ctx, buffer); + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + engine.stop(); + expect(() => engine.stop()).not.toThrow(); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // setLoop + // ----------------------------------------------------------------------- + + describe("setLoop", () => { + it("enables looping at runtime", () => { + const engine = createEngine(ctx, buffer, { loop: false }); + const loopHandler = vi.fn(); + engine.on("loop", loopHandler); + + engine.start(); + + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + + // Enable loop + engine.setLoop(true); + + // Advance to end + const src0 = findActiveSource(ctx._sources); + src0!.onended!(); + const src1 = findActiveSource(ctx._sources); + src1!.onended!(); + const src2 = findActiveSource(ctx._sources); + src2!.onended!(); + + expect(loopHandler).toHaveBeenCalledTimes(1); + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // dispose + // ----------------------------------------------------------------------- + + describe("dispose", () => { + it("cleans up all resources", () => { + const engine = createEngine(ctx, buffer); + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + expect(() => engine.dispose()).not.toThrow(); + }); + + it("operations after dispose are safe", () => { + const engine = createEngine(ctx, buffer); + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + engine.dispose(); + + expect(() => { + engine.start(); + engine.pause(); + engine.resume(); + engine.seek(5); + engine.stop(); + engine.setTempo(2); + engine.setLoop(true); + }).not.toThrow(); + }); + }); + + // ----------------------------------------------------------------------- + // onTransition – race with advanceToNextChunk + // ----------------------------------------------------------------------- + + describe("onTransition – race with advanceToNextChunk", () => { + it("does not double-increment currentChunkIndex", () => { + const engine = createEngine(ctx, buffer, { loop: false }); + + engine.start(); + + // Make all chunks ready + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + expect(engine.getSnapshot().currentChunkIndex).toBe(0); + + // Get chunk 0's source before transition + const src0 = findActiveSource(ctx._sources); + expect(src0).toBeDefined(); + + // Advance time close to end of chunk 0 → lookahead fires → scheduleNext + ctx._setCurrentTime(7.8); + vi.advanceTimersByTime(200); // LOOKAHEAD_INTERVAL_MS + + // Trigger onended on chunk 0's source: + // handleCurrentSourceEnded → nextSource exists → cancelTransition + doTransition → onTransition + src0!.onended!(); + + // currentChunkIndex should be exactly 1 + expect(engine.getSnapshot().currentChunkIndex).toBe(1); + + // Fire any remaining timers to ensure no stale transition causes double-increment + vi.advanceTimersByTime(5000); + + // Still 1 — no double increment + expect(engine.getSnapshot().currentChunkIndex).toBe(1); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // Error handling + // ----------------------------------------------------------------------- + + describe("error handling", () => { + it("retries chunk on first error (no error event emitted)", () => { + const errorHandler = vi.fn(); + const engine = createEngine(ctx, buffer); + engine.on("error", errorHandler); + + engine.start(); + + // First error triggers retry, not error event + workerStubs.simulateWorkerError(0, 0, "conversion failed"); + + // Error event is only emitted after MAX_CHUNK_RETRIES (3) + expect(errorHandler).not.toHaveBeenCalled(); + + engine.dispose(); + }); + + it("emits fatal error after max retries", () => { + const errorHandler = vi.fn(); + const engine = createEngine(ctx, buffer); + engine.on("error", errorHandler); + + engine.start(); + + // Fail chunk 0 three times (MAX_CHUNK_RETRIES = 3) + workerStubs.simulateWorkerError(0, 0, "fail1"); + workerStubs.simulateWorkerError(0, 0, "fail2"); + workerStubs.simulateWorkerError(0, 0, "fail3"); + + const fatalCall = errorHandler.mock.calls.find((call: any) => call[0].fatal === true); + expect(fatalCall).toBeDefined(); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // Event emission + // ----------------------------------------------------------------------- + + describe("event emission", () => { + it("emits progress event on chunk ready", () => { + const progressHandler = vi.fn(); + const engine = createEngine(ctx, buffer); + engine.on("progress", progressHandler); + + engine.start(); + + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + + expect(progressHandler).toHaveBeenCalledWith( + expect.objectContaining({ + total: 3, + ready: 1, + }), + ); + + engine.dispose(); + }); + + it("emits bufferhealth event", () => { + const healthHandler = vi.fn(); + const engine = createEngine(ctx, buffer); + engine.on("bufferhealth", healthHandler); + + engine.start(); + + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + + expect(healthHandler).toHaveBeenCalled(); + + engine.dispose(); + }); + + it("emits complete when all chunks ready", () => { + const completeHandler = vi.fn(); + const engine = createEngine(ctx, buffer); + engine.on("complete", completeHandler); + + engine.start(); + + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + + expect(completeHandler).toHaveBeenCalledTimes(1); + + engine.dispose(); + }); + + it("emits buffering event on start", () => { + const bufferingHandler = vi.fn(); + const engine = createEngine(ctx, buffer); + engine.on("buffering", bufferingHandler); + + engine.start(); + + expect(bufferingHandler).toHaveBeenCalledWith({ reason: "initial" }); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // S-01: buffering 中の stop + // ----------------------------------------------------------------------- + + describe("S-01: buffering 中の stop", () => { + it("stop during buffering transitions to ended", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + expect(engine.getStatus().phase).toBe("buffering"); + + // Stop during buffering (before any chunks are ready) + engine.stop(); + expect(engine.getStatus().phase).toBe("ended"); + + // Worker result arriving after stop should not crash + expect(() => { + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + }).not.toThrow(); + + expect(engine.getStatus().phase).toBe("ended"); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // S-02: buffering 中の dispose + // ----------------------------------------------------------------------- + + describe("S-02: buffering 中の dispose", () => { + it("dispose during buffering is safe", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + expect(engine.getStatus().phase).toBe("buffering"); + + // dispose should not throw + expect(() => engine.dispose()).not.toThrow(); + + // Operations after dispose should be safe + expect(() => { + engine.start(); + engine.pause(); + engine.resume(); + engine.seek(5); + engine.stop(); + }).not.toThrow(); + }); + + it("dispose during buffering terminates workers (handlers nulled)", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + expect(engine.getStatus().phase).toBe("buffering"); + + // Workers should have onmessage handlers before dispose + const workersBefore = workerStubs.workers.slice(-2); + expect(workersBefore[0]!.onmessage).not.toBeNull(); + + engine.dispose(); + + // After dispose, workers should be terminated (handlers nulled) + expect(workersBefore[0]!.onmessage).toBeNull(); + expect(workersBefore[0]!.onerror).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // S-03: pause → seek → resume + // ----------------------------------------------------------------------- + + describe("S-03: pause → seek → resume", () => { + it("seek during pause updates position, resume plays from seek target", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + engine.pause(); + expect(engine.getStatus().phase).toBe("paused"); + + // Seek to 12s (in chunk 1) + engine.seek(12); + + // Resume should play from near the seek position + engine.resume(); + + // Should be playing (chunk 1 is ready) + expect(engine.getStatus().phase).toBe("playing"); + + const pos = engine.getCurrentPosition(); + expect(pos).toBeGreaterThanOrEqual(11); + expect(pos).toBeLessThanOrEqual(13); + + engine.dispose(); + }); + + it("seek during pause to unready chunk → resume enters buffering", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + engine.pause(); + + // Seek to chunk 2 (not ready) + engine.seek(18); + + // Resume → should enter buffering since target chunk is not ready + engine.resume(); + + // Phase should be buffering (chunk 2 not ready) + // or playing if chunk 2 happens to be ready + const phase = engine.getStatus().phase; + expect(["buffering", "playing"]).toContain(phase); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // S-04: ended 後の seek + // ----------------------------------------------------------------------- + + describe("S-04: ended 後の seek", () => { + it("seek after stop does not crash and phase remains ended", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + engine.stop(); + expect(engine.getStatus().phase).toBe("ended"); + + // Seek after ended — should not crash + expect(() => engine.seek(10)).not.toThrow(); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // S-05: ended 後の pause / resume + // ----------------------------------------------------------------------- + + describe("S-05: ended 後の pause / resume", () => { + it("pause and resume after stop are no-ops", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + engine.stop(); + expect(engine.getStatus().phase).toBe("ended"); + + // These should be no-ops without crash + expect(() => engine.pause()).not.toThrow(); + expect(engine.getStatus().phase).toBe("ended"); + + expect(() => engine.resume()).not.toThrow(); + expect(engine.getStatus().phase).toBe("ended"); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // C-03: pause 中に onTransition タイマーが発火 + // ----------------------------------------------------------------------- + + describe("C-03: pause 中の transition タイマー", () => { + it("pause cancels transition timer, chunk index is preserved", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + expect(engine.getStatus().phase).toBe("playing"); + expect(engine.getSnapshot().currentChunkIndex).toBe(0); + + // Advance time near end of chunk 0 → lookahead triggers scheduleNext + ctx._setCurrentTime(7.5); + vi.advanceTimersByTime(200); + + // Pause before transition timer fires + engine.pause(); + expect(engine.getStatus().phase).toBe("paused"); + + const idxAfterPause = engine.getSnapshot().currentChunkIndex; + + // Advance time well past what would have been the transition + vi.advanceTimersByTime(10000); + + // currentChunkIndex should not have changed + expect(engine.getSnapshot().currentChunkIndex).toBe(idxAfterPause); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // C-01: seek 中に onTransition が発火 (stale transition guard) + // ----------------------------------------------------------------------- + + describe("C-01: seek cancels stale transition", () => { + it("seek during pending transition prevents stale transition", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + // Advance time near end of chunk 0 → scheduleNext for chunk 1 + ctx._setCurrentTime(7.5); + vi.advanceTimersByTime(200); + + // Now seek to chunk 2 (this should cancel the pending transition) + engine.seek(16); + + const idxAfterSeek = engine.getSnapshot().currentChunkIndex; + expect(idxAfterSeek).toBe(2); + + // Advance past old transition timer + vi.advanceTimersByTime(10000); + + // currentChunkIndex should still be 2, not reverted to 1 + expect(engine.getSnapshot().currentChunkIndex).toBe(2); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // C-02: setTempo debounce 中に onTransition が発火 + // ----------------------------------------------------------------------- + + describe("C-02: setTempo during pending transition", () => { + it("setTempo enters buffering and cancels pending transition", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + // Advance time near end of chunk 0 → scheduleNext for chunk 1 + ctx._setCurrentTime(7.5); + vi.advanceTimersByTime(200); + + const idxBefore = engine.getSnapshot().currentChunkIndex; + + // setTempo causes buffering → chunkPlayer.pause() → cancelTransition + engine.setTempo(2.0); + expect(engine.getStatus().phase).toBe("buffering"); + + // Advance past old transition timer + vi.advanceTimersByTime(10000); + + // currentChunkIndex should not have advanced from the stale transition + expect(engine.getSnapshot().currentChunkIndex).toBe(idxBefore); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // proactive + lookahead mutual exclusion + // ----------------------------------------------------------------------- + + describe("proactive + lookahead mutual exclusion", () => { + it("proactive first → lookahead does not re-schedule", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + + // Only chunk 0 ready first → exits buffering (aheadSec=8 ≥ resumeSec=5) + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + // Advance time so remaining < PROACTIVE_SCHEDULE_THRESHOLD_SEC (5.0) + // chunk 0 output duration ≈ CHUNK0_RAW / 44100 ≈ 8.2s + ctx._setCurrentTime(4.0); // elapsed=4, remaining≈4.2 < 5 + + // Record createBuffer call count before proactive fires + const createBufferMock = ctx.createBuffer as ReturnType; + const callsBefore = createBufferMock.mock.calls.length; + + // chunk 1 ready → onChunkReady → proactive fires (remaining < 5s) + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + const callsAfterProactive = createBufferMock.mock.calls.length; + expect(callsAfterProactive).toBe(callsBefore + 1); // one schedule for chunk 1 + + // Advance lookahead timer → should NOT create another buffer + vi.advanceTimersByTime(200); // LOOKAHEAD_INTERVAL_MS + expect(createBufferMock.mock.calls.length).toBe(callsAfterProactive); + + engine.dispose(); + }); + + it("lookahead first → proactive does not re-schedule", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + + // chunk 0 and chunk 1 both ready → exit buffering, playing chunk 0 + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + // Advance time so remaining < LOOKAHEAD_THRESHOLD_SEC (3.0) + ctx._setCurrentTime(5.5); // elapsed=5.5, remaining≈2.7 < 3 + + const createBufferMock = ctx.createBuffer as ReturnType; + const callsBefore = createBufferMock.mock.calls.length; + + // Advance lookahead timer → lookahead fires → schedules chunk 1 + vi.advanceTimersByTime(200); + const callsAfterLookahead = createBufferMock.mock.calls.length; + expect(callsAfterLookahead).toBe(callsBefore + 1); + + // chunk 2 ready → onChunkReady fires but chunk 2 != currentChunkIndex+1 + // and chunk 1 already scheduled → no re-schedule + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + expect(createBufferMock.mock.calls.length).toBe(callsAfterLookahead); + + engine.dispose(); + }); + + it("does not schedule when remaining <= 0", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + + // chunk 0 ready → exits buffering, playing chunk 0 + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + // Advance time past chunk 0's output duration (≈8.2s) + ctx._setCurrentTime(9.0); // elapsed=9, remaining≈-0.8 ≤ 0 + + const createBufferMock = ctx.createBuffer as ReturnType; + const callsBefore = createBufferMock.mock.calls.length; + + // chunk 1 ready → onChunkReady → proactive check: remaining ≤ 0 → should NOT schedule + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + expect(createBufferMock.mock.calls.length).toBe(callsBefore); + + // Also: lookahead should not schedule when remaining ≤ 0 + vi.advanceTimersByTime(200); + expect(createBufferMock.mock.calls.length).toBe(callsBefore); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // R-01: 連続 seek(seek→seek→seek) + // ----------------------------------------------------------------------- + + describe("R-01: 連続 seek", () => { + it("rapid seek calls don't crash and final position is correct", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + // Rapid consecutive seeks + engine.seek(3); + engine.seek(10); + engine.seek(20); + + // Should be at chunk 2 (position 20) + const pos = engine.getCurrentPosition(); + expect(pos).toBeGreaterThanOrEqual(19); + expect(pos).toBeLessThanOrEqual(21); + expect(engine.getSnapshot().currentChunkIndex).toBe(2); + + engine.dispose(); + }); + + it("rapid seek during pause ends at final position", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + + engine.pause(); + + engine.seek(3); + engine.seek(10); + engine.seek(5); + + engine.resume(); + expect(engine.getStatus().phase).toBe("playing"); + + const pos = engine.getCurrentPosition(); + expect(pos).toBeGreaterThanOrEqual(4); + expect(pos).toBeLessThanOrEqual(6); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // C-04: onChunkReady→exitBuffering→advanceToNextChunk 循環再入 + // ----------------------------------------------------------------------- + + describe("C-04: exitBuffering → advanceToNextChunk recursion", () => { + it("onChunkReady during buffering triggers exitBuffering safely", () => { + const engine = createEngine(ctx, buffer, { tempo: 2.0 }); + + engine.start(); + expect(engine.getStatus().phase).toBe("buffering"); + + // Provide chunks — exitBuffering should fire + workerStubs.simulateWorkerResult(0, 0, Math.round(CHUNK0_RAW / 2)); + workerStubs.simulateWorkerResult(1, 1, Math.round(CHUNK1_RAW / 2)); + + // Should be playing now + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // C-05: onTransition→updatePriorities→即座 onChunkReady + // ----------------------------------------------------------------------- + + describe("C-05: transition followed by immediate chunk ready", () => { + it("transition + updatePriorities + onChunkReady in sequence does not crash", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + // Advance to end of chunk 0 → onended + const src0 = findActiveSource(ctx._sources); + src0!.onended!(); + + // Should have advanced to chunk 1 + expect(engine.getSnapshot().currentChunkIndex).toBe(1); + + // Now chunk 2 becomes ready immediately after transition + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + + // Should not crash, complete event should fire + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // R-02: rapid pause→resume toggle + // ----------------------------------------------------------------------- + + describe("R-02: rapid pause→resume toggle", () => { + it("rapid pause→resume cycles don't cause errors", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + // Rapid toggle + for (let i = 0; i < 10; i++) { + engine.pause(); + engine.resume(); + } + + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + + it("rapid toggle preserves approximate position", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + ctx._setCurrentTime(3); + const posBeforeToggles = engine.getCurrentPosition(); + + engine.pause(); + engine.resume(); + engine.pause(); + engine.resume(); + + const posAfterToggles = engine.getCurrentPosition(); + // Position should be close (within a small tolerance due to mock timing) + expect(posAfterToggles).toBeGreaterThanOrEqual(posBeforeToggles - 1); + expect(posAfterToggles).toBeLessThanOrEqual(posBeforeToggles + 1); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // S-06: pause→setTempo→seek→resume + // ----------------------------------------------------------------------- + + describe("S-06: pause→setTempo→seek→resume", () => { + it("combined operations don't crash and end in correct state", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + engine.pause(); + engine.setTempo(2.0); + engine.seek(10); + engine.resume(); + + // Should be buffering (tempo change requires re-conversion) + expect(engine.getStatus().phase).toBe("buffering"); + expect(engine.getStatus().playback.tempo).toBe(2.0); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // S-07: pause 中の setLoop + // ----------------------------------------------------------------------- + + describe("S-07: pause 中の setLoop", () => { + it("setLoop during pause takes effect on resume", () => { + const engine = createEngine(ctx, buffer, { loop: false }); + const loopHandler = vi.fn(); + engine.on("loop", loopHandler); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + engine.pause(); + engine.setLoop(true); + engine.resume(); + + // Advance through all chunks + const src0 = findActiveSource(ctx._sources); + src0!.onended!(); + const src1 = findActiveSource(ctx._sources); + src1!.onended!(); + const src2 = findActiveSource(ctx._sources); + src2!.onended!(); + + // Should loop instead of ending + expect(loopHandler).toHaveBeenCalledTimes(1); + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // S-08: pause 中の dispose + // ----------------------------------------------------------------------- + + describe("S-08: pause 中の dispose", () => { + it("dispose during pause is clean", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + engine.pause(); + expect(engine.getStatus().phase).toBe("paused"); + + expect(() => engine.dispose()).not.toThrow(); + + // All operations after dispose should be safe + expect(() => { + engine.pause(); + engine.resume(); + engine.seek(5); + engine.setTempo(2); + }).not.toThrow(); + }); + }); + + // ----------------------------------------------------------------------- + // S-09: pause→pause 冪等性 + // ----------------------------------------------------------------------- + + describe("S-09: pause→pause 冪等性", () => { + it("double pause is idempotent", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + engine.pause(); + const posAfterFirstPause = engine.getCurrentPosition(); + + engine.pause(); // second pause should be no-op + const posAfterSecondPause = engine.getCurrentPosition(); + + expect(posAfterSecondPause).toBe(posAfterFirstPause); + expect(engine.getStatus().phase).toBe("paused"); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // S-10: start 直後の setTempo + // ----------------------------------------------------------------------- + + describe("S-10: start 直後の setTempo", () => { + it("setTempo immediately after start during buffering", () => { + const startWorkerIdx = workerStubs.workers.length; + const engine = createEngine(ctx, buffer, { tempo: 1.0 }); + + engine.start(); + expect(engine.getStatus().phase).toBe("buffering"); + + // setTempo during initial buffering + engine.setTempo(2.0); + expect(engine.getStatus().phase).toBe("buffering"); + expect(engine.getStatus().playback.tempo).toBe(2.0); + + // Advance debounce timer → handleTempoChange fires → cancelCurrent + reset + vi.advanceTimersByTime(100); + + // Simulate cancel responses to free worker slots + workerStubs.simulateWorkerCancel(startWorkerIdx, 0); + workerStubs.simulateWorkerCancel(startWorkerIdx + 1, 1); + + // Now provide chunks at new tempo (new dispatch should have been triggered) + workerStubs.simulateWorkerResult(startWorkerIdx, 0, Math.round(CHUNK0_RAW / 2)); + workerStubs.simulateWorkerResult(startWorkerIdx + 1, 1, Math.round(CHUNK1_RAW / 2)); + + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // L-03: dispose 後のコールバック参照ガード + // ----------------------------------------------------------------------- + + describe("L-03: dispose 後のコールバック参照ガード", () => { + it("callbacks from chunkPlayer after dispose are guarded", () => { + const engine = createEngine(ctx, buffer); + const errorHandler = vi.fn(); + engine.on("error", errorHandler); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + expect(engine.getStatus().phase).toBe("playing"); + + // Grab the source before dispose + const src = findActiveSource(ctx._sources); + + engine.dispose(); + + // If somehow onended fires after dispose (stale reference), it should not crash + // Note: dispose nulls out source.onended, so this is testing the edge case + // where the reference was captured before dispose + if (src?.onended) { + expect(() => src.onended!()).not.toThrow(); + } + + // No error event should have been emitted + expect(errorHandler).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // R-03: start 直後の pause(buffering 完了前) + // ----------------------------------------------------------------------- + + describe("R-03: start 直後の pause(buffering 完了前)", () => { + it("R-03a: start → pause → chunk arrives → resume → playing", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + expect(engine.getStatus().phase).toBe("buffering"); + + // Pause during initial buffering + engine.pause(); + expect(engine.getStatus().phase).toBe("paused"); + + // Chunks arrive during pause — should NOT trigger exitBuffering + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + // Phase should still be paused + expect(engine.getStatus().phase).toBe("paused"); + + // Resume — chunk is ready, so should play + engine.resume(); + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + + it("R-03b: start → pause → resume (chunk not ready) → buffering → chunk arrives → playing", () => { + const engine = createEngine(ctx, buffer); + + engine.start(); + expect(engine.getStatus().phase).toBe("buffering"); + + // Pause during initial buffering (no chunks ready yet) + engine.pause(); + expect(engine.getStatus().phase).toBe("paused"); + + // Resume without any chunks ready → enters buffering + engine.resume(); + expect(engine.getStatus().phase).toBe("buffering"); + + // Chunks arrive → exitBuffering → playing + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + }); +}); diff --git a/tests/stretcher/engine-background-resilience.test.ts b/tests/stretcher/engine-background-resilience.test.ts new file mode 100644 index 0000000..ab2ab2e --- /dev/null +++ b/tests/stretcher/engine-background-resilience.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PROACTIVE_SCHEDULE_THRESHOLD_SEC } from "../../src/stretcher/constants"; + +// Mock Worker and URL APIs (same pattern as engine.test.ts) +const mockWorker = { + postMessage: vi.fn(), + terminate: vi.fn(), + onmessage: null as ((e: MessageEvent) => void) | null, + onerror: null as ((e: ErrorEvent) => void) | null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), +}; + +vi.stubGlobal( + "Worker", + vi.fn(function MockWorkerCtor(this: typeof mockWorker) { + Object.assign(this, { ...mockWorker }); + }), +); +const OriginalURL = globalThis.URL; +vi.stubGlobal( + "URL", + Object.assign( + function MockURL(...args: ConstructorParameters) { + return new OriginalURL(...args); + } as unknown as typeof URL, + { + createObjectURL: vi.fn(() => "blob:mock"), + revokeObjectURL: vi.fn(), + prototype: OriginalURL.prototype, + canParse: OriginalURL.canParse, + }, + ), +); +vi.stubGlobal("Blob", vi.fn()); + +describe("createStretcherEngine – background tab resilience", () => { + let createStretcherEngine: typeof import("../../src/stretcher/engine").createStretcherEngine; + + beforeEach(async () => { + vi.clearAllMocks(); + const mod = await import("../../src/stretcher/engine"); + createStretcherEngine = mod.createStretcherEngine; + }); + + function createMockAudioContext(): AudioContext { + const mockCtx = { + currentTime: 0, + sampleRate: 44100, + destination: {} as AudioDestinationNode, + createBufferSource: vi.fn(() => ({ + buffer: null, + playbackRate: { value: 1 }, + loop: false, + loopStart: 0, + loopEnd: 0, + connect: vi.fn(), + disconnect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + onended: null, + })), + createGain: vi.fn(() => ({ + gain: { + value: 1, + setValueAtTime: vi.fn(), + linearRampToValueAtTime: vi.fn(), + setValueCurveAtTime: vi.fn(), + }, + connect: vi.fn(), + disconnect: vi.fn(), + })), + createBuffer: vi.fn((channels: number, length: number, sampleRate: number) => { + const channelData: Float32Array[] = []; + for (let i = 0; i < channels; i++) { + channelData.push(new Float32Array(length)); + } + return { + numberOfChannels: channels, + length, + sampleRate, + duration: length / sampleRate, + getChannelData: (ch: number) => channelData[ch]!, + }; + }), + } as unknown as AudioContext; + return mockCtx; + } + + function createMockAudioBuffer( + durationSec: number, + sampleRate = 44100, + channels = 1, + ): AudioBuffer { + const length = Math.round(durationSec * sampleRate); + const channelData: Float32Array[] = []; + for (let i = 0; i < channels; i++) { + channelData.push(new Float32Array(length)); + } + return { + numberOfChannels: channels, + length, + sampleRate, + duration: durationSec, + getChannelData: (ch: number) => channelData[ch]!, + } as unknown as AudioBuffer; + } + + it("PROACTIVE_SCHEDULE_THRESHOLD_SEC is wider than LOOKAHEAD_THRESHOLD_SEC", async () => { + const { LOOKAHEAD_THRESHOLD_SEC } = await import("../../src/stretcher/constants"); + expect(PROACTIVE_SCHEDULE_THRESHOLD_SEC).toBeGreaterThan(LOOKAHEAD_THRESHOLD_SEC); + }); + + it("PROACTIVE_SCHEDULE_THRESHOLD_SEC is at least 5 seconds", () => { + expect(PROACTIVE_SCHEDULE_THRESHOLD_SEC).toBeGreaterThanOrEqual(5.0); + }); + + it("onChunkReady triggers proactive scheduling for next chunk when playing", () => { + const ctx = createMockAudioContext(); + const buffer = createMockAudioBuffer(60); + + const engine = createStretcherEngine(ctx, buffer, { tempo: 1.0 }); + + const chunkReadyHandler = vi.fn(); + engine.on("chunkready", chunkReadyHandler); + + engine.start(); + expect(engine.getStatus().phase).toBe("buffering"); + + engine.dispose(); + }); + + it("engine enters buffering when next chunk is not ready at transition", () => { + const ctx = createMockAudioContext(); + const buffer = createMockAudioBuffer(60); + + const engine = createStretcherEngine(ctx, buffer, { tempo: 1.0 }); + + const bufferingHandler = vi.fn(); + engine.on("buffering", bufferingHandler); + + engine.start(); + expect(bufferingHandler).toHaveBeenCalledWith({ reason: "initial" }); + expect(engine.getStatus().phase).toBe("buffering"); + + engine.dispose(); + }); + + it("onended fallback path: engine handles chunk transition without timer", () => { + const ctx = createMockAudioContext(); + const buffer = createMockAudioBuffer(60); + + const engine = createStretcherEngine(ctx, buffer, { tempo: 1.0 }); + + engine.start(); + + // Engine starts in buffering — verify it can handle stop without crash + engine.stop(); + expect(engine.getStatus().phase).toBe("ended"); + + engine.dispose(); + }); + + it("disposed engine ignores onChunkReady callback", () => { + const ctx = createMockAudioContext(); + const buffer = createMockAudioBuffer(60); + + const engine = createStretcherEngine(ctx, buffer, { tempo: 1.0 }); + + const chunkReadyHandler = vi.fn(); + engine.on("chunkready", chunkReadyHandler); + + engine.start(); + engine.dispose(); + + // After dispose, no further chunkready events should be emitted + // (emitter is cleared) + expect(() => engine.getStatus()).not.toThrow(); + }); +}); diff --git a/tests/stretcher/engine-chunk-management.test.ts b/tests/stretcher/engine-chunk-management.test.ts new file mode 100644 index 0000000..4a95304 --- /dev/null +++ b/tests/stretcher/engine-chunk-management.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createMockAudioBuffer, + createMockAudioContext, + findActiveSource, + type MockAudioContext, + stubWorkerGlobals, +} from "../helpers/audio-mocks"; + +const workerStubs = stubWorkerGlobals(); + +// 24s audio, sampleRate=44100 → 3 chunks (CHUNK_DURATION_SEC=8) +const CHUNK0_RAW = 361620; +const CHUNK1_RAW = 370440; +const CHUNK2_RAW = 361620; + +let createStretcherEngine: typeof import("../../src/stretcher/engine")["createStretcherEngine"]; + +describe("engine – chunk management (advanceToNextChunk / evict)", () => { + let ctx: MockAudioContext; + let buffer: AudioBuffer; + + beforeEach(async () => { + vi.useFakeTimers(); + workerStubs.workers.length = 0; + vi.clearAllMocks(); + const mod = await import("../../src/stretcher/engine"); + createStretcherEngine = mod.createStretcherEngine; + ctx = createMockAudioContext(); + buffer = createMockAudioBuffer(24); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function createEngine(opts?: Partial<{ tempo: number; loop: boolean; offset: number }>) { + return createStretcherEngine(ctx, buffer, { + tempo: opts?.tempo ?? 1.0, + loop: opts?.loop ?? false, + offset: opts?.offset ?? 0, + }); + } + + it("loop: chunk 0 evicted → enters buffering on loop wrap", () => { + // Use a 50-second buffer for more chunks (7 chunks) + const longBuffer = createMockAudioBuffer(50); + const engine = createStretcherEngine(ctx, longBuffer, { + tempo: 1.0, + loop: true, + offset: 0, + }); + const loopHandler = vi.fn(); + const bufferingHandler = vi.fn(); + engine.on("loop", loopHandler); + engine.on("buffering", bufferingHandler); + + engine.start(); + + // Make chunks 0-6 ready + const chunkSizes = [361620, 370440, 370440, 370440, 370440, 370440, 352800]; + for (let i = 0; i < chunkSizes.length; i++) { + const workerIdx = i % 2; + workerStubs.simulateWorkerResult(workerIdx, i, chunkSizes[i]!); + } + + expect(engine.getStatus().phase).toBe("playing"); + + // Advance through all chunks to trigger loop + for (let i = 0; i < chunkSizes.length; i++) { + const src = findActiveSource(ctx._sources); + if (src) src.onended!(); + } + + // Loop should have fired + expect(loopHandler).toHaveBeenCalled(); + + engine.dispose(); + }); + + it("next chunk not ready → enters buffering → ready → exits to playing", () => { + const engine = createEngine(); + const bufferingHandler = vi.fn(); + const bufferedHandler = vi.fn(); + engine.on("buffering", bufferingHandler); + engine.on("buffered", bufferedHandler); + + engine.start(); + + // Make only chunk 0 ready + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + // Advance past chunk 0 → chunk 1 not ready → buffering + const src0 = findActiveSource(ctx._sources); + src0!.onended!(); + + expect(engine.getStatus().phase).toBe("buffering"); + + // Make chunk 1 ready → should exit buffering + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + expect(bufferedHandler).toHaveBeenCalled(); + + engine.dispose(); + }); + + it("setTempo during playback triggers reconversion", () => { + const engine = createEngine(); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + // Change tempo + engine.setTempo(1.5); + + // Engine should have triggered re-conversion (entering buffering while chunks reconvert) + const status = engine.getStatus(); + expect(status.phase === "buffering" || status.phase === "playing").toBe(true); + + engine.dispose(); + }); + + it("short audio (< 8 sec, single chunk) — all operations work", () => { + const shortBuffer = createMockAudioBuffer(5); // 5 sec → 1 chunk + const engine = createStretcherEngine(ctx, shortBuffer, { + tempo: 1.0, + loop: false, + offset: 0, + }); + const endedHandler = vi.fn(); + engine.on("ended", endedHandler); + + engine.start(); + + // Make the single chunk ready + const outputLen = Math.round(5 * 44100); // ~221550 + workerStubs.simulateWorkerResult(0, 0, outputLen); + + expect(engine.getStatus().phase).toBe("playing"); + + // Seek within the single chunk + engine.seek(2.5); + expect(engine.getCurrentPosition()).toBeGreaterThanOrEqual(0); + + // Pause/resume + engine.pause(); + expect(engine.getStatus().phase).toBe("paused"); + + engine.resume(); + expect(engine.getStatus().phase).toBe("playing"); + + // Advance past the single chunk → ended + const src = findActiveSource(ctx._sources); + if (src) src.onended!(); + + expect(endedHandler).toHaveBeenCalledTimes(1); + expect(engine.getStatus().phase).toBe("ended"); + + engine.dispose(); + }); + + it("seek triggers onended from previous source → advanceToNextChunk handles gracefully", () => { + const engine = createEngine(); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + // Seek to a different chunk + engine.seek(10); + + // Position should be near 10 + const pos = engine.getCurrentPosition(); + expect(pos).toBeGreaterThanOrEqual(9); + expect(pos).toBeLessThanOrEqual(11); + + // Now simulate onended from the seek's source + const src = findActiveSource(ctx._sources); + if (src) src.onended!(); + + // Should either advance to next chunk or end — no crash + const status = engine.getStatus(); + expect(["playing", "buffering", "ended"]).toContain(status.phase); + + engine.dispose(); + }); + + it("getSnapshot returns valid data", () => { + const engine = createEngine(); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + + const snapshot = engine.getSnapshot(); + expect(snapshot).toBeDefined(); + expect(snapshot.currentChunkIndex).toBeGreaterThanOrEqual(0); + expect(snapshot.totalChunks).toBeGreaterThan(0); + expect(typeof snapshot.buffering).toBe("boolean"); + + engine.dispose(); + }); +}); diff --git a/tests/stretcher/engine-exitbuffering.test.ts b/tests/stretcher/engine-exitbuffering.test.ts new file mode 100644 index 0000000..ccde2d6 --- /dev/null +++ b/tests/stretcher/engine-exitbuffering.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createMockAudioBuffer, + createMockAudioContext, + type MockAudioContext, + stubWorkerGlobals, +} from "../helpers/audio-mocks"; + +const workerStubs = stubWorkerGlobals(); + +// 24s audio, sampleRate=44100 → 3 chunks (CHUNK_DURATION_SEC=8) +const CHUNK0_RAW = 361620; +const CHUNK1_RAW = 370440; +const CHUNK2_RAW = 361620; + +let createStretcherEngine: typeof import("../../src/stretcher/engine")["createStretcherEngine"]; + +describe("engine – exitBuffering paths", () => { + let ctx: MockAudioContext; + let buffer: AudioBuffer; + + beforeEach(async () => { + vi.useFakeTimers(); + workerStubs.workers.length = 0; + vi.clearAllMocks(); + const mod = await import("../../src/stretcher/engine"); + createStretcherEngine = mod.createStretcherEngine; + ctx = createMockAudioContext(); + buffer = createMockAudioBuffer(24); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function createEngine(opts?: Partial<{ tempo: number; loop: boolean; offset: number }>) { + return createStretcherEngine(ctx, buffer, { + tempo: opts?.tempo ?? 1.0, + loop: opts?.loop ?? false, + offset: opts?.offset ?? 0, + }); + } + + it("bufferingResumePosition=null → plays from chunk start with crossfade", () => { + const engine = createEngine(); + const bufferingHandler = vi.fn(); + const bufferedHandler = vi.fn(); + engine.on("buffering", bufferingHandler); + engine.on("buffered", bufferedHandler); + + engine.start(); + + // Initial buffering → chunk 0 becomes ready → exits buffering + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + + expect(bufferedHandler).toHaveBeenCalled(); + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + + it("resumes from buffering at chunk start when seek targets chunk start", () => { + const engine = createEngine(); + const bufferedHandler = vi.fn(); + engine.on("buffered", bufferedHandler); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + + // Seek to start of chunk 1 (~8s) + engine.seek(8); + + // If chunk 1 is already ready, it should exit buffering immediately + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + + it("enters buffering when seeking to unready chunk, exits when ready", () => { + const engine = createEngine(); + const bufferingHandler = vi.fn(); + const bufferedHandler = vi.fn(); + engine.on("buffering", bufferingHandler); + engine.on("buffered", bufferedHandler); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + + // Seek to chunk 2 which isn't ready yet + engine.seek(16); + + expect(engine.getStatus().phase).toBe("buffering"); + const callsBefore = bufferedHandler.mock.calls.length; + + // Now make chunk 2 ready + workerStubs.simulateWorkerResult(1, 2, CHUNK2_RAW); + + expect(bufferedHandler.mock.calls.length).toBeGreaterThan(callsBefore); + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + + it("handles exitBuffering after tempo change", () => { + const engine = createEngine({ tempo: 1.0 }); + const bufferedHandler = vi.fn(); + engine.on("buffered", bufferedHandler); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + // Change tempo + engine.setTempo(2.0); + + // Seek to chunk 2 (not ready) + engine.seek(16); + + expect(engine.getStatus().phase).toBe("buffering"); + + // Make chunk 2 ready with different output length (tempo=2) + workerStubs.simulateWorkerResult(1, 2, Math.round(CHUNK2_RAW / 2)); + + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); + + it("exits buffering correctly when chunk near end triggers advanceToNextChunk", () => { + const engine = createEngine(); + + engine.start(); + workerStubs.simulateWorkerResult(0, 0, CHUNK0_RAW); + workerStubs.simulateWorkerResult(1, 1, CHUNK1_RAW); + workerStubs.simulateWorkerResult(0, 2, CHUNK2_RAW); + + // The engine should be playing (all chunks ready) + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); +}); diff --git a/tests/stretcher/engine.test.ts b/tests/stretcher/engine.test.ts index 46dd021..6722ead 100644 --- a/tests/stretcher/engine.test.ts +++ b/tests/stretcher/engine.test.ts @@ -1,5 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { StretcherEngine, StretcherEvents } from "../../src/stretcher/types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; // We test the engine using mocked dependencies since Worker is not available in Node. // This focuses on the integration logic. @@ -14,11 +13,27 @@ const mockWorker = { removeEventListener: vi.fn(), }; -vi.stubGlobal("Worker", vi.fn(() => ({ ...mockWorker }))); -vi.stubGlobal("URL", { - createObjectURL: vi.fn(() => "blob:mock"), - revokeObjectURL: vi.fn(), -}); +vi.stubGlobal( + "Worker", + vi.fn(function MockWorkerCtor(this: typeof mockWorker) { + Object.assign(this, { ...mockWorker }); + }), +); +const OriginalURL = globalThis.URL; +vi.stubGlobal( + "URL", + Object.assign( + function MockURL(...args: ConstructorParameters) { + return new OriginalURL(...args); + } as unknown as typeof URL, + { + createObjectURL: vi.fn(() => "blob:mock"), + revokeObjectURL: vi.fn(), + prototype: OriginalURL.prototype, + canParse: OriginalURL.canParse, + }, + ), +); vi.stubGlobal("Blob", vi.fn()); describe("createStretcherEngine", () => { diff --git a/tests/stretcher/main-thread-processor.test.ts b/tests/stretcher/main-thread-processor.test.ts index fdaf5b0..d023bcb 100644 --- a/tests/stretcher/main-thread-processor.test.ts +++ b/tests/stretcher/main-thread-processor.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createMainThreadProcessor } from "../../src/stretcher/main-thread-processor"; import type { WorkerResponse } from "../../src/stretcher/types"; @@ -145,4 +145,84 @@ describe("createMainThreadProcessor", () => { expect(onResult.mock.calls[0]![0]!.chunkIndex).toBe(0); expect(onResult.mock.calls[1]![0]!.chunkIndex).toBe(1); }); + + // ----------------------------------------------------------------------- + // MTP: postTimes / cancelledChunks ライフサイクル + // ----------------------------------------------------------------------- + + describe("MTP: postTimes / cancelledChunks ライフサイクル", () => { + it("MTP-a: postTimes persist after result delivery", async () => { + const proc = createMainThreadProcessor(onResult, onError); + const input = [new Float32Array(4096).fill(0.5)]; + proc.postConvert(0, input, 1.0, 44100); + + // postTime should be set immediately + expect(proc.getPostTimeForChunk(0)).toBeTypeOf("number"); + + // Wait for result + await vi.waitFor(() => { + expect(onResult).toHaveBeenCalledTimes(1); + }); + + // postTimes should still contain the entry (no cleanup on result) + expect(proc.getPostTimeForChunk(0)).toBeTypeOf("number"); + expect(proc.getLastPostTime()).toBeTypeOf("number"); + }); + + it("MTP-b: cancelChunk then re-postConvert same index yields normal result", async () => { + const proc = createMainThreadProcessor(onResult, onError); + const input = [new Float32Array(4096).fill(0.5)]; + + // First conversion → cancel + proc.postConvert(0, input, 1.0, 44100); + proc.cancelChunk(0); + + // Wait for cancelled result + await vi.waitFor(() => { + expect(onResult).toHaveBeenCalledTimes(1); + }); + expect(onResult.mock.calls[0]![0]!.type).toBe("cancelled"); + + // Re-post same index → should produce normal result + proc.postConvert(0, input, 1.0, 44100); + + await vi.waitFor(() => { + expect(onResult).toHaveBeenCalledTimes(2); + }); + expect(onResult.mock.calls[1]![0]!.type).toBe("result"); + expect(onResult.mock.calls[1]![0]!.chunkIndex).toBe(0); + }); + + it("MTP-c: terminate clears postTimes and getLastPostTime", () => { + const proc = createMainThreadProcessor(onResult, onError); + const input = [new Float32Array(4096).fill(0.5)]; + proc.postConvert(0, input, 1.0, 44100); + + expect(proc.getPostTimeForChunk(0)).toBeTypeOf("number"); + expect(proc.getLastPostTime()).toBeTypeOf("number"); + + proc.terminate(); + + expect(proc.getPostTimeForChunk(0)).toBeNull(); + expect(proc.getLastPostTime()).toBeNull(); + }); + + it("MTP-d: terminate before setTimeout fires prevents callbacks", async () => { + const proc = createMainThreadProcessor(onResult, onError); + const input = [new Float32Array(4096).fill(0.5)]; + + // Post conversion (setTimeout(0) scheduled) + proc.postConvert(0, input, 1.0, 44100); + + // Terminate immediately before the setTimeout fires + proc.terminate(); + + // Wait to ensure the setTimeout would have fired + await new Promise((r) => setTimeout(r, 50)); + + // No callbacks should have been called + expect(onResult).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/stretcher/playback-lifecycle.test.ts b/tests/stretcher/playback-lifecycle.test.ts index a4b9776..812fc3a 100644 --- a/tests/stretcher/playback-lifecycle.test.ts +++ b/tests/stretcher/playback-lifecycle.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // --------------------------------------------------------------------------- // Enhanced mocks: capture individual Worker and AudioBufferSourceNode instances @@ -25,23 +25,31 @@ const bufferSourceInstances: Array<{ vi.stubGlobal( "Worker", - vi.fn(() => { - const worker = { - postMessage: vi.fn(), - terminate: vi.fn(), - onmessage: null as ((e: MessageEvent) => void) | null, - onerror: null as ((e: ErrorEvent) => void) | null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }; - workerInstances.push(worker); - return worker; + vi.fn(function MockWorkerCtor(this: Record) { + this.postMessage = vi.fn(); + this.terminate = vi.fn(); + this.onmessage = null; + this.onerror = null; + this.addEventListener = vi.fn(); + this.removeEventListener = vi.fn(); + workerInstances.push(this as unknown as (typeof workerInstances)[number]); }), ); -vi.stubGlobal("URL", { - createObjectURL: vi.fn(() => "blob:mock"), - revokeObjectURL: vi.fn(), -}); +const OriginalURL = globalThis.URL; +vi.stubGlobal( + "URL", + Object.assign( + function MockURL(...args: ConstructorParameters) { + return new OriginalURL(...args); + } as unknown as typeof URL, + { + createObjectURL: vi.fn(() => "blob:mock"), + revokeObjectURL: vi.fn(), + prototype: OriginalURL.prototype, + canParse: OriginalURL.canParse, + }, + ), +); vi.stubGlobal("Blob", vi.fn()); // --------------------------------------------------------------------------- @@ -76,29 +84,23 @@ function createMockAudioContext() { connect: vi.fn(), disconnect: vi.fn(), })), - createBuffer: vi.fn( - (channels: number, length: number, sampleRate: number) => { - const channelData: Float32Array[] = []; - for (let i = 0; i < channels; i++) { - channelData.push(new Float32Array(length)); - } - return { - numberOfChannels: channels, - length, - sampleRate, - duration: length / sampleRate, - getChannelData: (ch: number) => channelData[ch]!, - }; - }, - ), + createBuffer: vi.fn((channels: number, length: number, sampleRate: number) => { + const channelData: Float32Array[] = []; + for (let i = 0; i < channels; i++) { + channelData.push(new Float32Array(length)); + } + return { + numberOfChannels: channels, + length, + sampleRate, + duration: length / sampleRate, + getChannelData: (ch: number) => channelData[ch]!, + }; + }), } as unknown as AudioContext; } -function createMockAudioBuffer( - durationSec: number, - sampleRate = 44100, - channels = 1, -): AudioBuffer { +function createMockAudioBuffer(durationSec: number, sampleRate = 44100, channels = 1): AudioBuffer { const length = Math.round(durationSec * sampleRate); const channelData: Float32Array[] = []; for (let i = 0; i < channels; i++) { @@ -116,11 +118,7 @@ function createMockAudioBuffer( /** * Simulate a Worker result for a specific chunk on the given worker. */ -function simulateWorkerResult( - workerIndex: number, - chunkIndex: number, - outputLength: number, -) { +function simulateWorkerResult(workerIndex: number, chunkIndex: number, outputLength: number) { const worker = workerInstances[workerIndex]; if (!worker?.onmessage) { throw new Error(`Worker ${workerIndex} has no onmessage handler`); @@ -152,7 +150,7 @@ function findActiveSource() { // --------------------------------------------------------------------------- describe("playback lifecycle (chunk progression)", () => { - let createStretcherEngine: (typeof import("../../src/stretcher/engine"))["createStretcherEngine"]; + let createStretcherEngine: typeof import("../../src/stretcher/engine")["createStretcherEngine"]; beforeEach(async () => { vi.useFakeTimers(); @@ -173,9 +171,9 @@ describe("playback lifecycle (chunk progression)", () => { // Chunk 2: inputStart=696780, inputEnd=1058400, overlap=(8820,0) → nominal 705600–1058400 // Worker が返す raw WSOLA 出力(trim 前)= 入力長(tempo=1.0) - const CHUNK0_RAW = 361620; // nominal 8s + overlapAfter 0.2s - const CHUNK1_RAW = 370440; // nominal 8s + overlap 0.2s × 2 - const CHUNK2_RAW = 361620; // nominal 8s + overlapBefore 0.2s + const CHUNK0_RAW = 361620; // nominal 8s + overlapAfter 0.2s + const CHUNK1_RAW = 370440; // nominal 8s + overlap 0.2s × 2 + const CHUNK2_RAW = 361620; // nominal 8s + overlapBefore 0.2s // engine 内で trim 後に createBuffer に渡される長さ // Chunk 0: overlapBefore=0 → keepBefore=0, trimStart=0, trimEnd=8820 → 361620-0-8820=352800 @@ -213,8 +211,7 @@ describe("playback lifecycle (chunk progression)", () => { src!.onended!(); // chunk 1 が再生されるはず(trim 後の長さ) - const latestLen = - createBufferMock.mock.calls[createBufferMock.mock.calls.length - 1][1]; + const latestLen = createBufferMock.mock.calls[createBufferMock.mock.calls.length - 1][1]; expect(latestLen).toBe(CHUNK1_TRIMMED); expect(engine.getStatus().phase).toBe("playing"); @@ -248,8 +245,7 @@ describe("playback lifecycle (chunk progression)", () => { const callsAfterLookahead = createBufferMock.mock.calls.length; expect(callsAfterLookahead).toBeGreaterThan(callsBefore); // scheduleNext で作られた buffer は chunk 1 の trim 後の長さ - const scheduledLen = - createBufferMock.mock.calls[callsAfterLookahead - 1][1]; + const scheduledLen = createBufferMock.mock.calls[callsAfterLookahead - 1][1]; expect(scheduledLen).toBe(CHUNK1_TRIMMED); // --- Transition setTimeout 発火 --- @@ -311,4 +307,45 @@ describe("playback lifecycle (chunk progression)", () => { engine.dispose(); }); + + it("gapless パス: onended が transition より先に発火 → nextSource が即座に昇格", () => { + const ctx = createMockAudioContext(); + const buffer = createMockAudioBuffer(24); + const engine = createStretcherEngine(ctx, buffer, { tempo: 1.0 }); + + engine.start(); + + simulateWorkerResult(0, 0, CHUNK0_RAW); + simulateWorkerResult(1, 1, CHUNK1_RAW); + simulateWorkerResult(0, 2, CHUNK2_RAW); + + expect(engine.getStatus().phase).toBe("playing"); + + const createBufferMock = (ctx as any).createBuffer; + + // Lookahead → scheduleNext(chunk 1) + (ctx as any).currentTime = 7.6; + vi.advanceTimersByTime(200); + + const callsAfterSchedule = createBufferMock.mock.calls.length; + + // transition timer 発火前に onended をトリガー + // (バックグラウンドタブで setTimeout が遅延するケースを模倣) + const src = findActiveSource(); + expect(src).not.toBeNull(); + src!.onended!(); + + // KEY ASSERTION: 新ソースが作成されていない(createBuffer の追加呼び出しなし) + // → gapless の nextSource がそのまま昇格された + expect(createBufferMock.mock.calls.length).toBe(callsAfterSchedule); + + // 後続の transition timer が発火しても二重昇格しない + (ctx as any).currentTime = 8.0; + vi.advanceTimersByTime(500); + expect(createBufferMock.mock.calls.length).toBe(callsAfterSchedule); + + expect(engine.getStatus().phase).toBe("playing"); + + engine.dispose(); + }); }); diff --git a/tests/stretcher/position-calc.test.ts b/tests/stretcher/position-calc.test.ts new file mode 100644 index 0000000..c856815 --- /dev/null +++ b/tests/stretcher/position-calc.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import { + calcPositionInOriginalBuffer, + type PositionCalcParams, +} from "../../src/stretcher/position-calc"; + +function makeParams(overrides: Partial = {}): PositionCalcParams { + return { + phase: "playing", + totalDuration: 30, + offset: 0, + bufferingResumePosition: null, + currentTempo: 1.0, + sampleRate: 44100, + crossfadeSec: 0.1, + chunk: { + inputStartSample: 0, + overlapBefore: 0, + }, + posInChunk: 0, + ...overrides, + }; +} + +describe("calcPositionInOriginalBuffer", () => { + // --- Phase-based short-circuits --- + + it("returns totalDuration when phase is ended", () => { + expect(calcPositionInOriginalBuffer(makeParams({ phase: "ended" }))).toBe(30); + }); + + it("returns offset when phase is waiting", () => { + expect(calcPositionInOriginalBuffer(makeParams({ phase: "waiting", offset: 5 }))).toBe(5); + }); + + it("returns bufferingResumePosition when phase is buffering and resume position is set", () => { + expect( + calcPositionInOriginalBuffer( + makeParams({ phase: "buffering", bufferingResumePosition: 12.5 }), + ), + ).toBe(12.5); + }); + + it("falls through when phase is buffering but bufferingResumePosition is null", () => { + const result = calcPositionInOriginalBuffer( + makeParams({ phase: "buffering", bufferingResumePosition: null }), + ); + // Falls through to chunk-based calculation (chunk at 0, posInChunk=0 → 0) + expect(result).toBe(0); + }); + + it("returns bufferingResumePosition when phase is paused and resume position is set", () => { + expect( + calcPositionInOriginalBuffer(makeParams({ phase: "paused", bufferingResumePosition: 5.0 })), + ).toBe(5.0); + }); + + it("falls through when phase is paused but bufferingResumePosition is null", () => { + const result = calcPositionInOriginalBuffer( + makeParams({ phase: "paused", bufferingResumePosition: null, posInChunk: 2.0 }), + ); + // Falls through to chunk-based calculation + expect(result).toBe(2.0); + }); + + // --- chunk === null / undefined --- + + it("returns 0 when chunk is null (defensive)", () => { + expect(calcPositionInOriginalBuffer(makeParams({ chunk: null }))).toBe(0); + }); + + // --- First chunk (overlapBefore=0) --- + + it("calculates position for first chunk (no crossfade)", () => { + // inputStartSample=0, overlapBefore=0, nominalStart=0/44100=0 + // crossfadeOffset=0, adjustedPos=2.0, posInOriginal=2.0*1.0=2.0 + expect(calcPositionInOriginalBuffer(makeParams({ posInChunk: 2.0 }))).toBe(2.0); + }); + + // --- Middle chunk (overlapBefore > 0) --- + + it("calculates position for middle chunk with crossfade offset", () => { + // inputStartSample=44100, overlapBefore=4410, nominalStart=(44100+4410)/44100=1.1 + // crossfadeOffset=0.1 (overlapBefore>0), adjustedPos=max(0, 0.5-0.1)=0.4 + // posInOriginal=0.4*1.0=0.4, result=1.1+0.4=1.5 + expect( + calcPositionInOriginalBuffer( + makeParams({ + chunk: { inputStartSample: 44100, overlapBefore: 4410 }, + posInChunk: 0.5, + }), + ), + ).toBeCloseTo(1.5, 5); + }); + + // --- Boundary: posInChunk < crossfadeOffset --- + + it("clamps adjustedPos to 0 when posInChunk < crossfadeOffset", () => { + // overlapBefore>0 → crossfadeOffset=0.1, posInChunk=0.05 < 0.1 → adjustedPos=0 + // nominalStart=(0+4410)/44100=0.1, posInOriginal=0, result=0.1 + expect( + calcPositionInOriginalBuffer( + makeParams({ + chunk: { inputStartSample: 0, overlapBefore: 4410 }, + posInChunk: 0.05, + }), + ), + ).toBeCloseTo(0.1, 5); + }); + + // --- Tempo variations --- + + it("calculates correct position at tempo 2.0", () => { + // nominalStart=0, crossfadeOffset=0, adjustedPos=1.0 + // posInOriginal=1.0*2.0=2.0 + expect(calcPositionInOriginalBuffer(makeParams({ currentTempo: 2.0, posInChunk: 1.0 }))).toBe( + 2.0, + ); + }); + + it("calculates correct position at tempo 0.5", () => { + // nominalStart=0, crossfadeOffset=0, adjustedPos=1.0 + // posInOriginal=1.0*0.5=0.5 + expect(calcPositionInOriginalBuffer(makeParams({ currentTempo: 0.5, posInChunk: 1.0 }))).toBe( + 0.5, + ); + }); + + it("handles very slow tempo (0.01)", () => { + // posInChunk=10.0, posInOriginal=10.0*0.01=0.1 + expect( + calcPositionInOriginalBuffer(makeParams({ currentTempo: 0.01, posInChunk: 10.0 })), + ).toBeCloseTo(0.1, 5); + }); + + // --- Clamping to totalDuration --- + + it("clamps result to totalDuration", () => { + // nominalStart=(44100*29)/44100=29, posInChunk=5.0 → posInOriginal=5.0 + // 29+5=34 > totalDuration=30 → clamped to 30 + expect( + calcPositionInOriginalBuffer( + makeParams({ + chunk: { inputStartSample: 44100 * 29, overlapBefore: 0 }, + posInChunk: 5.0, + }), + ), + ).toBe(30); + }); + + // --- Negative posInChunk (defensive) --- + + it("handles negative posInChunk gracefully", () => { + // adjustedPos = max(0, -1.0 - 0) = 0 (first chunk, no crossfade) + // Wait, Math.max(0, -1.0 - 0) = 0 + const result = calcPositionInOriginalBuffer(makeParams({ posInChunk: -1.0 })); + expect(result).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/tests/stretcher/priority-queue.test.ts b/tests/stretcher/priority-queue.test.ts index 969456d..b460eec 100644 --- a/tests/stretcher/priority-queue.test.ts +++ b/tests/stretcher/priority-queue.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { createPriorityQueue } from "../../src/stretcher/priority-queue"; describe("createPriorityQueue", () => { @@ -63,9 +63,7 @@ describe("createPriorityQueue", () => { }); it("rebuild restores heap property after mutation", () => { - const q = createPriorityQueue<{ val: number; pri: number }>( - (a, b) => a.pri - b.pri, - ); + const q = createPriorityQueue<{ val: number; pri: number }>((a, b) => a.pri - b.pri); const items = [ { val: 1, pri: 10 }, diff --git a/tests/stretcher/transition-timing.test.ts b/tests/stretcher/transition-timing.test.ts new file mode 100644 index 0000000..4c47a23 --- /dev/null +++ b/tests/stretcher/transition-timing.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { calcTransitionDelay, TRANSITION_MARGIN_MS } from "../../src/stretcher/transition-timing"; + +describe("calcTransitionDelay", () => { + it("returns positive delay when startTime > currentTime", () => { + // (10 - 5) * 1000 + 50 = 5050 + expect(calcTransitionDelay(10, 5)).toBe(5050); + }); + + it("returns TRANSITION_MARGIN_MS when startTime === currentTime", () => { + // (5 - 5) * 1000 + 50 = 50 + expect(calcTransitionDelay(5, 5)).toBe(TRANSITION_MARGIN_MS); + }); + + it("returns 0 when startTime is far behind currentTime", () => { + // (0 - 10) * 1000 + 50 = -9950 → clamped to 0 + expect(calcTransitionDelay(0, 10)).toBe(0); + }); + + it("returns positive but small delay when startTime is slightly behind currentTime", () => { + // (9.99 - 10) * 1000 + 50 = -10 + 50 = 40 + expect(calcTransitionDelay(9.99, 10)).toBeCloseTo(40, 0); + }); + + it("handles fractional times", () => { + // (1.5 - 1.0) * 1000 + 50 = 550 + expect(calcTransitionDelay(1.5, 1.0)).toBe(550); + }); + + it("always returns >= 0", () => { + expect(calcTransitionDelay(-100, 100)).toBe(0); + expect(calcTransitionDelay(0, 0)).toBe(TRANSITION_MARGIN_MS); + expect(calcTransitionDelay(100, 0)).toBeGreaterThan(0); + }); + + it("TRANSITION_MARGIN_MS is 50", () => { + expect(TRANSITION_MARGIN_MS).toBe(50); + }); +}); diff --git a/tests/stretcher/trim-overlap.test.ts b/tests/stretcher/trim-overlap.test.ts new file mode 100644 index 0000000..31e0641 --- /dev/null +++ b/tests/stretcher/trim-overlap.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import { trimOverlap } from "../../src/stretcher/engine"; +import type { ChunkInfo } from "../../src/stretcher/types"; + +function makeChunk(overrides: Partial = {}): ChunkInfo { + return { + index: 0, + state: "ready", + inputStartSample: 0, + inputEndSample: 352800, // 8 seconds @ 44100 + overlapBefore: 0, + overlapAfter: 0, + outputBuffer: null, + outputLength: 0, + priority: 0, + retryCount: 0, + ...overrides, + }; +} + +function makeChannels(length: number, channels = 2): Float32Array[] { + return Array.from({ length: channels }, () => new Float32Array(length)); +} + +describe("trimOverlap", () => { + const SR = 44100; + + it("returns data as-is when inputLength is 0", () => { + const data = makeChannels(100); + const chunk = makeChunk({ inputStartSample: 0, inputEndSample: 0 }); + const result = trimOverlap(data, 100, chunk, SR); + expect(result.length).toBe(100); + expect(result.data).toBe(data); + }); + + it("returns data as-is when outputLength is 0", () => { + const data = makeChannels(0); + const chunk = makeChunk(); + const result = trimOverlap(data, 0, chunk, SR); + expect(result.length).toBe(0); + expect(result.data).toBe(data); + }); + + it("does not trim first chunk (overlapBefore=0, overlapAfter=0)", () => { + const data = makeChannels(352800); + const chunk = makeChunk(); + const result = trimOverlap(data, 352800, chunk, SR); + // No overlap → trimStart=0, trimEnd=0, newLength=352800 + expect(result.length).toBe(352800); + }); + + it("trims overlap for middle chunk (overlapBefore > 0, overlapAfter > 0)", () => { + const inputLen = 352800 + 2 * 8820; // 8sec + overlap before + after + const outputLen = inputLen; // ratio=1 at tempo=1 + const data = makeChannels(outputLen); + const chunk = makeChunk({ + inputStartSample: 44100, + inputEndSample: 44100 + inputLen, + overlapBefore: 8820, // 0.2 sec overlap + overlapAfter: 8820, + }); + const result = trimOverlap(data, outputLen, chunk, SR); + // ratio=1, crossfadeKeep=round(0.1*44100)=4410 + // overlapBeforeOutput=8820, keepBefore=min(4410,8820)=4410 + // trimStart=8820-4410=4410, trimEnd=8820 + // newLength=outputLen-4410-8820 + const expectedLen = outputLen - 4410 - 8820; + expect(result.length).toBe(expectedLen); + }); + + it("trims last chunk (overlapBefore > 0, overlapAfter=0)", () => { + const inputLen = 352800 + 8820; // overlap before only + const data = makeChannels(inputLen); + const chunk = makeChunk({ + index: 5, + inputStartSample: 100000, + inputEndSample: 100000 + inputLen, + overlapBefore: 8820, + overlapAfter: 0, + }); + const result = trimOverlap(data, inputLen, chunk, SR); + // trimStart=8820-4410=4410, trimEnd=0, newLength=inputLen-4410 + expect(result.length).toBe(inputLen - 4410); + }); + + it("returns original data when newLength <= 0 (fallback)", () => { + // Very short chunk where trimming would exceed length + const data = makeChannels(100); + const chunk = makeChunk({ + inputStartSample: 0, + inputEndSample: 100, + overlapBefore: 80, + overlapAfter: 80, + }); + const result = trimOverlap(data, 100, chunk, SR); + // ratio=100/100=1, overlapBeforeOutput=80, crossfadeKeep=4410 + // keepBefore=min(4410,80)=80, trimStart=80-80=0 + // trimEnd=80, newLength=100-0-80=20 > 0 → trimmed + // Actually this may not trigger <= 0 ... + // Let's verify + expect(result.length).toBe(20); + }); + + it("handles extreme ratio (tempo=0.1 → ratio ~= 10)", () => { + const inputLen = 352800; + const outputLen = inputLen * 10; // slow tempo + const data = makeChannels(outputLen); + const chunk = makeChunk({ + inputStartSample: 44100, + inputEndSample: 44100 + inputLen, + overlapBefore: 8820, + overlapAfter: 8820, + }); + const result = trimOverlap(data, outputLen, chunk, SR); + // ratio=10, overlapBeforeOutput=88200, crossfadeKeep=4410 + // keepBefore=min(4410,88200)=4410, trimStart=88200-4410=83790 + // overlapAfterOutput=88200, trimEnd=88200 + // newLength = 3528000 - 83790 - 88200 = 3356010 + expect(result.length).toBe(3528000 - 83790 - 88200); + }); + + it("trims overlap at high tempo (ratio=0.5, tempo 2.0x)", () => { + const inputLen = 352800 + 2 * 8820; // 8sec + overlap + const outputLen = Math.round(inputLen * 0.5); // tempo 2.0x → ratio=0.5 + const data = makeChannels(outputLen); + const chunk = makeChunk({ + inputStartSample: 44100, + inputEndSample: 44100 + inputLen, + overlapBefore: 8820, + overlapAfter: 8820, + }); + const result = trimOverlap(data, outputLen, chunk, SR); + // ratio=0.5, crossfadeKeep=round(0.1*44100*0.5)=2205 + // overlapBeforeOutput=round(8820*0.5)=4410, keepBefore=min(2205,4410)=2205 + // trimStart=4410-2205=2205 + // overlapAfterOutput=round(8820*0.5)=4410, trimEnd=4410 + // newLength=outputLen-2205-4410 + const expectedLen = outputLen - 2205 - 4410; + expect(result.length).toBe(expectedLen); + // Ensure trimStart > 0 (the actual bug being fixed) + expect(expectedLen).toBeLessThan(outputLen); + }); + + it("trims overlap at extreme tempo (ratio=0.25, tempo 4.0x)", () => { + const inputLen = 352800 + 2 * 8820; + const outputLen = Math.round(inputLen * 0.25); // tempo 4.0x → ratio=0.25 + const data = makeChannels(outputLen); + const chunk = makeChunk({ + inputStartSample: 44100, + inputEndSample: 44100 + inputLen, + overlapBefore: 8820, + overlapAfter: 8820, + }); + const result = trimOverlap(data, outputLen, chunk, SR); + // ratio=0.25, crossfadeKeep=round(0.1*44100*0.25)=1103 + // overlapBeforeOutput=round(8820*0.25)=2205, keepBefore=min(1103,2205)=1103 + // trimStart=2205-1103=1102 + // overlapAfterOutput=round(8820*0.25)=2205, trimEnd=2205 + // newLength=outputLen-1102-2205 + const expectedLen = outputLen - 1102 - 2205; + expect(result.length).toBe(expectedLen); + expect(expectedLen).toBeLessThan(outputLen); + }); + + it("handles crossfadeKeep > overlapBeforeOutput (min branch)", () => { + // overlapBefore=100 samples, ratio=1 + // overlapBeforeOutput=100, crossfadeKeep=4410 + // keepBefore=min(4410,100)=100 → trimStart=100-100=0 + const data = makeChannels(50000); + const chunk = makeChunk({ + inputStartSample: 10000, + inputEndSample: 60000, + overlapBefore: 100, + overlapAfter: 0, + }); + const result = trimOverlap(data, 50000, chunk, SR); + // keepBefore=min(4410,100)=100, trimStart=100-100=0, trimEnd=0 + expect(result.length).toBe(50000); + }); +}); diff --git a/tests/stretcher/worker-manager.test.ts b/tests/stretcher/worker-manager.test.ts new file mode 100644 index 0000000..2059ba0 --- /dev/null +++ b/tests/stretcher/worker-manager.test.ts @@ -0,0 +1,713 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { stubWorkerGlobals } from "../helpers/audio-mocks"; + +// --------------------------------------------------------------------------- +// Setup Worker stubs +// --------------------------------------------------------------------------- + +const workerStubs = stubWorkerGlobals(); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +let createWorkerManager: typeof import("../../src/stretcher/worker-manager")["createWorkerManager"]; + +describe("createWorkerManager", () => { + let onResult: ReturnType; + let onError: ReturnType; + + beforeEach(async () => { + workerStubs.workers.length = 0; + vi.clearAllMocks(); + onResult = vi.fn(); + onError = vi.fn(); + const mod = await import("../../src/stretcher/worker-manager"); + createWorkerManager = mod.createWorkerManager; + }); + + // ----------------------------------------------------------------------- + // Pool initialization + // ----------------------------------------------------------------------- + + describe("pool initialization", () => { + it("creates the specified number of workers", () => { + createWorkerManager(onResult, onError, 3, 2); + expect(workerStubs.workers.length).toBe(2); + }); + + it("creates default number of workers (WORKER_POOL_SIZE)", () => { + createWorkerManager(onResult, onError); + // Default pool size is 2 + expect(workerStubs.workers.length).toBe(2); + }); + + it("calls onAllDead if all workers fail to spawn", () => { + // Make Worker constructor throw + const originalWorker = globalThis.Worker; + vi.stubGlobal( + "Worker", + vi.fn(function MockWorkerCtor() { + throw new Error("Worker not supported"); + }), + ); + + const onAllDead = vi.fn(); + createWorkerManager(onResult, onError, 3, 2, onAllDead); + + expect(onAllDead).toHaveBeenCalledTimes(1); + + // Restore + vi.stubGlobal("Worker", originalWorker); + }); + }); + + // ----------------------------------------------------------------------- + // postConvert + // ----------------------------------------------------------------------- + + describe("postConvert", () => { + it("posts a convert message to a free worker", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + const inputData = [new Float32Array(1024)]; + + manager.postConvert(0, inputData, 1.0, 44100); + + const worker = workerStubs.workers[0]!; + expect(worker.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "convert", + chunkIndex: 0, + tempo: 1.0, + sampleRate: 44100, + }), + expect.any(Array), // transferables + ); + }); + + it("distributes work across multiple workers", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + const data1 = [new Float32Array(1024)]; + const data2 = [new Float32Array(1024)]; + + manager.postConvert(0, data1, 1.0, 44100); + manager.postConvert(1, data2, 1.0, 44100); + + expect(workerStubs.workers[0]!.postMessage).toHaveBeenCalledTimes(1); + expect(workerStubs.workers[1]!.postMessage).toHaveBeenCalledTimes(1); + }); + + it("does nothing when no free workers available", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + + // Fill both workers + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + manager.postConvert(1, [new Float32Array(1024)], 1.0, 44100); + + // Third call should not crash + const data3 = [new Float32Array(1024)]; + manager.postConvert(2, data3, 1.0, 44100); + + // Only 2 calls total (one per worker) + const totalCalls = workerStubs.workers.reduce( + (sum, w) => sum + w.postMessage.mock.calls.length, + 0, + ); + expect(totalCalls).toBe(2); + }); + + it("does nothing after terminate", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + manager.terminate(); + + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + expect(workerStubs.workers[0]!.postMessage).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // Worker onmessage – result + // ----------------------------------------------------------------------- + + describe("worker onmessage – result", () => { + it("calls onResult and frees worker slot on result", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + + workerStubs.simulateWorkerResult(0, 0, 512); + + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + type: "result", + chunkIndex: 0, + }), + ); + + // Worker slot should be free now + expect(manager.hasCapacity()).toBe(true); + }); + }); + + // ----------------------------------------------------------------------- + // Worker onmessage – cancelled + // ----------------------------------------------------------------------- + + describe("worker onmessage – cancelled", () => { + it("calls onResult and frees worker slot on cancel", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + + workerStubs.simulateWorkerCancel(0, 0); + + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + type: "cancelled", + chunkIndex: 0, + }), + ); + + expect(manager.hasCapacity()).toBe(true); + }); + }); + + // ----------------------------------------------------------------------- + // Worker onmessage – error + // ----------------------------------------------------------------------- + + describe("worker onmessage – error", () => { + it("calls onError on error response", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + + workerStubs.simulateWorkerError(0, 0, "conversion failed"); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + chunkIndex: 0, + error: "conversion failed", + }), + ); + + // onResult should not be called for errors + expect(onResult).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // Worker onerror – crash handling + // ----------------------------------------------------------------------- + + describe("worker onerror – crash handling", () => { + it("respawns worker on crash under maxCrashes", () => { + const workerCountBefore = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 3, 2); + const initialWorkerCount = workerStubs.workers.length; + + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + + workerStubs.simulateWorkerCrash(workerCountBefore, "Crash!"); + + // A new worker should have been spawned + expect(workerStubs.workers.length).toBe(initialWorkerCount + 1); + + // onError should have been called for the failed chunk + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + chunkIndex: 0, + }), + ); + }); + + it("does not respawn after maxCrashes exceeded", () => { + const startIdx = workerStubs.workers.length; + const onAllDead = vi.fn(); + const manager = createWorkerManager(onResult, onError, 2, 1, onAllDead); + const afterInit = workerStubs.workers.length; + + // First crash → respawn + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + workerStubs.simulateWorkerCrash(startIdx, "Crash 1"); + expect(workerStubs.workers.length).toBe(afterInit + 1); + + // Second crash → no more respawn (maxCrashes = 2) + const respawnedIdx = workerStubs.workers.length - 1; + manager.postConvert(1, [new Float32Array(1024)], 1.0, 44100); + workerStubs.simulateWorkerCrash(respawnedIdx, "Crash 2"); + + // onAllDead should be called since the only slot is dead + expect(onAllDead).toHaveBeenCalled(); + }); + + it("onAllDead is called exactly once when poolSize=2 and both workers reach maxCrashes", () => { + const onAllDead = vi.fn(); + const startIdx = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 2, 2, onAllDead); + + // Crash slot[0] twice (maxCrashes=2) + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + workerStubs.simulateWorkerCrash(startIdx, "Crash 1"); + // Respawned → workers[startIdx+2] + manager.postConvert(1, [new Float32Array(1024)], 1.0, 44100); + workerStubs.simulateWorkerCrash(startIdx + 2, "Crash 2"); + // slot[0] dead, slot[1] still alive → onAllDead not yet + + // Crash slot[1] twice + manager.postConvert(2, [new Float32Array(1024)], 1.0, 44100); + workerStubs.simulateWorkerCrash(startIdx + 1, "Crash 3"); + // Respawned → workers[startIdx+3] + manager.postConvert(3, [new Float32Array(1024)], 1.0, 44100); + workerStubs.simulateWorkerCrash(startIdx + 3, "Crash 4"); + // Both dead → onAllDead fires + + expect(onAllDead).toHaveBeenCalledTimes(1); + }); + + it("onAllDead is called exactly once when all workers fail to spawn (poolSize=3)", () => { + const originalWorker = globalThis.Worker; + vi.stubGlobal( + "Worker", + vi.fn(function MockWorkerCtor() { + throw new Error("Worker not supported"); + }), + ); + + const onAllDead = vi.fn(); + createWorkerManager(onResult, onError, 3, 3, onAllDead); + + expect(onAllDead).toHaveBeenCalledTimes(1); + + vi.stubGlobal("Worker", originalWorker); + }); + }); + + // ----------------------------------------------------------------------- + // cancelCurrent + // ----------------------------------------------------------------------- + + describe("cancelCurrent", () => { + it("sends cancel message to all busy workers", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + const startIdx = workerStubs.workers.length - 2; + + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + manager.postConvert(1, [new Float32Array(1024)], 1.0, 44100); + + manager.cancelCurrent(); + + expect(workerStubs.workers[startIdx]!.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "cancel", chunkIndex: 0 }), + ); + expect(workerStubs.workers[startIdx + 1]!.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "cancel", chunkIndex: 1 }), + ); + }); + + it("does nothing after terminate", () => { + const startIdx = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 3, 2); + + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + manager.terminate(); + + const worker = workerStubs.workers[startIdx]!; + const callsBefore = worker.postMessage.mock.calls.length; + + manager.cancelCurrent(); + + // No additional postMessage calls after terminate + expect(worker.postMessage.mock.calls.length).toBe(callsBefore); + }); + }); + + // ----------------------------------------------------------------------- + // cancelChunk + // ----------------------------------------------------------------------- + + describe("cancelChunk", () => { + it("sends cancel message to the worker processing that chunk", () => { + const startIdx = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 3, 2); + + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + manager.postConvert(1, [new Float32Array(1024)], 1.0, 44100); + + manager.cancelChunk(0); + + expect(workerStubs.workers[startIdx]!.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "cancel", chunkIndex: 0 }), + ); + // Worker 1 should not receive cancel + const worker1PostCalls = workerStubs.workers[startIdx + 1]!.postMessage.mock.calls; + const cancelCalls = worker1PostCalls.filter((call: any) => call[0]?.type === "cancel"); + expect(cancelCalls.length).toBe(0); + }); + + it("does nothing if chunk is not being processed", () => { + const startIdx = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 3, 2); + + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + + // Cancel chunk 5 (not being processed) + manager.cancelChunk(5); + + // Only the original postConvert call + const totalPostCalls = workerStubs.workers + .slice(startIdx) + .reduce((sum, w) => sum + w.postMessage.mock.calls.length, 0); + expect(totalPostCalls).toBe(1); + }); + }); + + // ----------------------------------------------------------------------- + // isBusy / hasCapacity + // ----------------------------------------------------------------------- + + describe("isBusy / hasCapacity", () => { + it("isBusy returns false initially", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + expect(manager.isBusy()).toBe(false); + }); + + it("hasCapacity returns true initially", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + expect(manager.hasCapacity()).toBe(true); + }); + + it("isBusy returns true when all workers are busy", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + manager.postConvert(1, [new Float32Array(1024)], 1.0, 44100); + expect(manager.isBusy()).toBe(true); + }); + + it("hasCapacity returns false when all workers are busy", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + manager.postConvert(1, [new Float32Array(1024)], 1.0, 44100); + expect(manager.hasCapacity()).toBe(false); + }); + + it("slot becomes free after result", () => { + const startIdx = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 3, 2); + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + manager.postConvert(1, [new Float32Array(1024)], 1.0, 44100); + + expect(manager.hasCapacity()).toBe(false); + + // Complete one + workerStubs.simulateWorkerResult(startIdx, 0, 512); + + expect(manager.hasCapacity()).toBe(true); + expect(manager.isBusy()).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // getCurrentChunkIndex + // ----------------------------------------------------------------------- + + describe("getCurrentChunkIndex", () => { + it("returns null when no workers are busy", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + expect(manager.getCurrentChunkIndex()).toBeNull(); + }); + + it("returns the first busy worker's chunk index", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + manager.postConvert(5, [new Float32Array(1024)], 1.0, 44100); + expect(manager.getCurrentChunkIndex()).toBe(5); + }); + }); + + // ----------------------------------------------------------------------- + // getLastPostTime / getPostTimeForChunk + // ----------------------------------------------------------------------- + + describe("getLastPostTime / getPostTimeForChunk", () => { + it("getLastPostTime returns null initially", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + expect(manager.getLastPostTime()).toBeNull(); + }); + + it("getPostTimeForChunk returns null for unknown chunk", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + expect(manager.getPostTimeForChunk(99)).toBeNull(); + }); + + it("records post time for chunk", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + + const postTime = manager.getPostTimeForChunk(0); + expect(postTime).not.toBeNull(); + expect(typeof postTime).toBe("number"); + }); + + it("getLastPostTime returns the most recent time", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + manager.postConvert(1, [new Float32Array(1024)], 1.0, 44100); + + const lastTime = manager.getLastPostTime(); + const time0 = manager.getPostTimeForChunk(0); + const time1 = manager.getPostTimeForChunk(1); + + expect(lastTime).not.toBeNull(); + expect(lastTime).toBe(Math.max(time0!, time1!)); + }); + + it("clears postTime after result received", () => { + const startIdx = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 3, 2); + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + + expect(manager.getPostTimeForChunk(0)).not.toBeNull(); + + workerStubs.simulateWorkerResult(startIdx, 0, 512); + + expect(manager.getPostTimeForChunk(0)).toBeNull(); + }); + + it("clears postTime after cancelled received", () => { + const startIdx = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 3, 2); + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + + workerStubs.simulateWorkerCancel(startIdx, 0); + + expect(manager.getPostTimeForChunk(0)).toBeNull(); + }); + + it("clears postTime after error received", () => { + const startIdx = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 3, 2); + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + + workerStubs.simulateWorkerError(startIdx, 0, "conversion failed"); + + expect(manager.getPostTimeForChunk(0)).toBeNull(); + }); + + it("clears postTime after worker crash (onerror)", () => { + const startIdx = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 3, 2); + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + + workerStubs.simulateWorkerCrash(startIdx, "Crash!"); + + expect(manager.getPostTimeForChunk(0)).toBeNull(); + }); + + it("all postTimes are cleaned up after 10 chunk convert→result cycles", () => { + const startIdx = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 3, 2); + + for (let i = 0; i < 10; i++) { + // Use slot 0 (startIdx) for odd, slot 1 (startIdx+1) for even + const slotIdx = startIdx + (i % 2); + manager.postConvert(i, [new Float32Array(1024)], 1.0, 44100); + workerStubs.simulateWorkerResult(slotIdx, i, 512); + } + + for (let i = 0; i < 10; i++) { + expect(manager.getPostTimeForChunk(i)).toBeNull(); + } + expect(manager.getLastPostTime()).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // W-01: cancelChunk → result arrives + // ----------------------------------------------------------------------- + + describe("cancelChunk then result arrives", () => { + it("W-01: result after cancelChunk still calls onResult and frees slot", () => { + const startIdx = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 3, 2); + + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + expect(manager.hasCapacity()).toBe(true); // still has second slot + + // Cancel chunk 0 (sends cancel message, but worker may still return result) + manager.cancelChunk(0); + + // Worker processes the cancel and responds with "cancelled" + workerStubs.simulateWorkerCancel(startIdx, 0); + + // onResult should be called with the cancelled response + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + type: "cancelled", + chunkIndex: 0, + }), + ); + + // Slot should be freed + expect(manager.hasCapacity()).toBe(true); + }); + + it("W-01: result message after cancelChunk still calls onResult and frees slot", () => { + const startIdx = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 3, 2); + + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + + // Cancel chunk 0 + manager.cancelChunk(0); + + // Worker didn't process cancel in time and sends result instead + workerStubs.simulateWorkerResult(startIdx, 0, 512); + + // onResult should be called (cancel was a best-effort message) + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + type: "result", + chunkIndex: 0, + }), + ); + + // Slot should be freed + expect(manager.hasCapacity()).toBe(true); + // postTime should be cleaned up + expect(manager.getPostTimeForChunk(0)).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // W-02: terminate 後の onmessage/onerror + // ----------------------------------------------------------------------- + + describe("terminate then worker responds", () => { + it("W-02: onmessage handler is nulled after terminate", () => { + const startIdx = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 3, 2); + + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + + const worker = workerStubs.workers[startIdx]!; + expect(worker.onmessage).not.toBeNull(); + + manager.terminate(); + + // onmessage and onerror should have been nulled + expect(worker.onmessage).toBeNull(); + expect(worker.onerror).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // W-03: respawn 中の postConvert + // ----------------------------------------------------------------------- + + describe("postConvert during respawn", () => { + it("W-03: postConvert works after worker respawn", () => { + const startIdx = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 3, 2); + + // Crash first worker → respawn + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + workerStubs.simulateWorkerCrash(startIdx, "Crash!"); + + // Respawned worker should exist + const respawnedIdx = workerStubs.workers.length - 1; + expect(workerStubs.workers[respawnedIdx]).toBeDefined(); + + // Should be able to post to respawned worker + manager.postConvert(1, [new Float32Array(1024)], 1.0, 44100); + + // At least one worker should have received the postConvert + const totalPostCalls = workerStubs.workers + .slice(startIdx) + .reduce( + (sum, w) => + sum + + w.postMessage.mock.calls.filter((call: any) => call[0]?.type === "convert").length, + 0, + ); + expect(totalPostCalls).toBeGreaterThanOrEqual(2); + }); + }); + + // ----------------------------------------------------------------------- + // M-02: postTimes cleanup on cancel/error + // ----------------------------------------------------------------------- + + describe("postTimes cleanup", () => { + it("M-02: postTimes cleaned up after cancelCurrent + cancel response", () => { + const startIdx = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 3, 2); + + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + manager.postConvert(1, [new Float32Array(1024)], 1.0, 44100); + + expect(manager.getPostTimeForChunk(0)).not.toBeNull(); + expect(manager.getPostTimeForChunk(1)).not.toBeNull(); + + manager.cancelCurrent(); + + // Simulate cancel responses + workerStubs.simulateWorkerCancel(startIdx, 0); + workerStubs.simulateWorkerCancel(startIdx + 1, 1); + + expect(manager.getPostTimeForChunk(0)).toBeNull(); + expect(manager.getPostTimeForChunk(1)).toBeNull(); + expect(manager.getLastPostTime()).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // M-01: terminate timing + // ----------------------------------------------------------------------- + + describe("terminate timing", () => { + it("M-01: terminate during postConvert → subsequent ops are safe", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + + // Post a convert then immediately terminate + manager.postConvert(0, [new Float32Array(1024)], 1.0, 44100); + manager.terminate(); + + // All subsequent operations should be safe + expect(() => manager.postConvert(1, [new Float32Array(1024)], 1.0, 44100)).not.toThrow(); + expect(() => manager.cancelCurrent()).not.toThrow(); + expect(() => manager.cancelChunk(0)).not.toThrow(); + // After terminate, all workers are null so no capacity + expect(manager.hasCapacity()).toBe(false); + // postTimes are cleared by terminate + expect(manager.getLastPostTime()).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // terminate + // ----------------------------------------------------------------------- + + describe("terminate", () => { + it("terminates all workers", () => { + const startIdx = workerStubs.workers.length; + const manager = createWorkerManager(onResult, onError, 3, 2); + + manager.terminate(); + + for (let i = startIdx; i < workerStubs.workers.length; i++) { + expect(workerStubs.workers[i]!.terminate).toHaveBeenCalled(); + } + }); + + it("double terminate is safe", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + manager.terminate(); + expect(() => manager.terminate()).not.toThrow(); + }); + + it("revokes blob URL on terminate", () => { + const manager = createWorkerManager(onResult, onError, 3, 2); + manager.terminate(); + expect(URL.revokeObjectURL).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/stretcher/wsola.test.ts b/tests/stretcher/wsola.test.ts index 001e491..eadc6cf 100644 --- a/tests/stretcher/wsola.test.ts +++ b/tests/stretcher/wsola.test.ts @@ -1,9 +1,6 @@ -import { describe, it, expect } from "vitest"; -import { - createHannWindow, - findBestOffset, - wsolaTimeStretch, -} from "../../src/stretcher/wsola"; +import { describe, expect, it } from "vitest"; +import { WSOLA_FRAME_SIZE, WSOLA_HOP_SIZE } from "../../src/stretcher/constants"; +import { createHannWindow, findBestOffset, wsolaTimeStretch } from "../../src/stretcher/wsola"; describe("createHannWindow", () => { it("returns a Float32Array of the given size", () => { @@ -65,11 +62,7 @@ describe("findBestOffset", () => { }); describe("wsolaTimeStretch", () => { - function createSineWave( - freq: number, - sampleRate: number, - durationSec: number, - ): Float32Array { + function createSineWave(freq: number, sampleRate: number, durationSec: number): Float32Array { const length = Math.round(sampleRate * durationSec); const data = new Float32Array(length); for (let i = 0; i < length; i++) { @@ -78,10 +71,7 @@ describe("wsolaTimeStretch", () => { return data; } - function estimatePeakFrequency( - signal: Float32Array, - sampleRate: number, - ): number { + function estimatePeakFrequency(signal: Float32Array, sampleRate: number): number { // Simple zero-crossing based frequency estimation let crossings = 0; for (let i = 1; i < signal.length; i++) { @@ -100,11 +90,7 @@ describe("wsolaTimeStretch", () => { }); it("returns empty arrays for zero-length channels", () => { - const result = wsolaTimeStretch( - [new Float32Array(0)], - 1.5, - 44100, - ); + const result = wsolaTimeStretch([new Float32Array(0)], 1.5, 44100); expect(result.output).toHaveLength(1); expect(result.output[0]!.length).toBe(0); }); @@ -169,4 +155,165 @@ describe("wsolaTimeStretch", () => { expect(result.output).toHaveLength(1); expect(result.length).toBe(input.length); }); + + it("produces uniform boundary discontinuity with compound wave (continuity)", () => { + const sampleRate = 44100; + const duration = 2.0; + const length = Math.round(sampleRate * duration); + const input = new Float32Array(length); + for (let i = 0; i < length; i++) { + input[i] = + 0.6 * Math.sin((2 * Math.PI * 440 * i) / sampleRate) + + 0.4 * Math.sin((2 * Math.PI * 1100 * i) / sampleRate); + } + + const result = wsolaTimeStretch([input], 1.3, sampleRate); + const out = result.output[0]!; + const synthesisHop = WSOLA_HOP_SIZE; + + const boundaryDiffs: number[] = []; + const nonBoundaryDiffs: number[] = []; + + const startFrame = 2; + const endSample = result.length - WSOLA_FRAME_SIZE; + for (let i = startFrame * synthesisHop; i < endSample; i++) { + const diff = Math.abs(out[i]! - out[i - 1]!); + if (i % synthesisHop === 0) { + boundaryDiffs.push(diff); + } else { + nonBoundaryDiffs.push(diff); + } + } + + const avgBoundary = boundaryDiffs.reduce((a, b) => a + b, 0) / boundaryDiffs.length; + const avgNonBoundary = nonBoundaryDiffs.reduce((a, b) => a + b, 0) / nonBoundaryDiffs.length; + const ratio = avgBoundary / avgNonBoundary; + + // With windowed prevOutputFrame, boundary/non-boundary ratio should be + // very close to 1.0 (uniform discontinuity distribution). + // Raw prevFrame: ~0.988, windowed prevFrame: ~0.9996 + expect(Math.abs(ratio - 1.0)).toBeLessThan(0.005); + }); + + describe("tempo≈1.0 identity bypass", () => { + it("returns exact copy at tempo=1.0", () => { + const sampleRate = 44100; + const input = createSineWave(440, sampleRate, 1.0); + const result = wsolaTimeStretch([input], 1.0, sampleRate); + + expect(result.length).toBe(input.length); + // MSE should be exactly 0 + let mse = 0; + for (let i = 0; i < input.length; i++) { + const diff = result.output[0]![i]! - input[i]!; + mse += diff * diff; + } + mse /= input.length; + expect(mse).toBe(0); + }); + + it("applies bypass within epsilon (tempo=0.9995)", () => { + const sampleRate = 44100; + const input = createSineWave(440, sampleRate, 1.0); + const result = wsolaTimeStretch([input], 0.9995, sampleRate); + + expect(result.length).toBe(input.length); + let mse = 0; + for (let i = 0; i < input.length; i++) { + const diff = result.output[0]![i]! - input[i]!; + mse += diff * diff; + } + mse /= input.length; + expect(mse).toBe(0); + }); + + it("does NOT bypass outside epsilon (tempo=1.002)", () => { + const sampleRate = 44100; + const input = createSineWave(440, sampleRate, 1.0); + const result = wsolaTimeStretch([input], 1.002, sampleRate); + + // Normal WSOLA should produce different length + expect(result.length).not.toBe(input.length); + }); + + it("handles multi-channel identity", () => { + const sampleRate = 44100; + const ch0 = createSineWave(440, sampleRate, 1.0); + const ch1 = createSineWave(880, sampleRate, 1.0); + const result = wsolaTimeStretch([ch0, ch1], 1.0, sampleRate); + + expect(result.output).toHaveLength(2); + expect(result.length).toBe(ch0.length); + + for (let i = 0; i < ch0.length; i++) { + expect(result.output[0]![i]).toBe(ch0[i]); + expect(result.output[1]![i]).toBe(ch1[i]); + } + }); + + it("returns independent copy (mutation does not affect input)", () => { + const sampleRate = 44100; + const input = createSineWave(440, sampleRate, 0.5); + const originalFirst = input[0]!; + const result = wsolaTimeStretch([input], 1.0, sampleRate); + + // Mutate output + result.output[0]![0] = 999; + expect(input[0]).toBe(originalFirst); + }); + }); + + it("produces uniform energy at frame boundaries (energy stability)", () => { + const sampleRate = 44100; + const input = createSineWave(440, sampleRate, 2.0); + const result = wsolaTimeStretch([input], 1.5, sampleRate); + const out = result.output[0]!; + const synthesisHop = WSOLA_HOP_SIZE; + const halfHop = Math.floor(synthesisHop / 2); + + const windowSize = 64; + const halfWin = Math.floor(windowSize / 2); + const boundaryEnergies: number[] = []; + const nonBoundaryEnergies: number[] = []; + + const startFrame = 2; + const endSample = result.length - WSOLA_FRAME_SIZE; + + for (let frame = startFrame; frame * synthesisHop < endSample; frame++) { + const bCenter = frame * synthesisHop; + const nbCenter = bCenter + halfHop; + + let bEnergy = 0; + let nbEnergy = 0; + for (let j = -halfWin; j < halfWin; j++) { + const bIdx = bCenter + j; + const nbIdx = nbCenter + j; + if (bIdx >= 0 && bIdx < result.length) { + bEnergy += out[bIdx]! * out[bIdx]!; + } + if (nbIdx >= 0 && nbIdx < result.length) { + nbEnergy += out[nbIdx]! * out[nbIdx]!; + } + } + boundaryEnergies.push(bEnergy); + nonBoundaryEnergies.push(nbEnergy); + } + + const meanBE = boundaryEnergies.reduce((a, b) => a + b, 0) / boundaryEnergies.length; + const varBE = + boundaryEnergies.reduce((a, b) => a + (b - meanBE) ** 2, 0) / boundaryEnergies.length; + const cvBoundary = Math.sqrt(varBE) / meanBE; + + const meanNBE = nonBoundaryEnergies.reduce((a, b) => a + b, 0) / nonBoundaryEnergies.length; + const varNBE = + nonBoundaryEnergies.reduce((a, b) => a + (b - meanNBE) ** 2, 0) / nonBoundaryEnergies.length; + const cvNonBoundary = Math.sqrt(varNBE) / meanNBE; + + const ratio = cvBoundary / cvNonBoundary; + + // With windowed prevOutputFrame, boundary/non-boundary energy CV ratio + // should be very close to 1.0 (uniform energy distribution). + // Raw prevFrame: ~0.9977, windowed prevFrame: ~1.0000 + expect(Math.abs(ratio - 1.0)).toBeLessThan(0.001); + }); }); diff --git a/tests/synth.test.ts b/tests/synth.test.ts new file mode 100644 index 0000000..7fa2216 --- /dev/null +++ b/tests/synth.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { createClickBuffer, createNoiseBuffer, createSineBuffer } from "../src/synth.js"; +import { createMockAudioContext } from "./helpers/audio-mocks.js"; + +describe("synth", () => { + // ------------------------------------------------------------------------- + // createSineBuffer + // ------------------------------------------------------------------------- + describe("createSineBuffer", () => { + it("creates a buffer with the correct length", () => { + const ctx = createMockAudioContext({ sampleRate: 44100 }); + const buf = createSineBuffer(ctx, 440, 1); + expect(ctx.createBuffer).toHaveBeenCalledWith(1, 44100, 44100); + expect(buf).toBeDefined(); + }); + + it("fills data with a sine wave pattern", () => { + const ctx = createMockAudioContext({ sampleRate: 44100 }); + const buf = createSineBuffer(ctx, 440, 0.01); + const data = buf.getChannelData(0); + // First sample should be sin(0) = 0 + expect(data[0]).toBeCloseTo(0, 5); + // Check that values oscillate (not all zero) + let hasPositive = false; + let hasNegative = false; + for (let i = 0; i < data.length; i++) { + if (data[i]! > 0.1) hasPositive = true; + if (data[i]! < -0.1) hasNegative = true; + } + expect(hasPositive).toBe(true); + expect(hasNegative).toBe(true); + }); + + it("correctly generates known sine values", () => { + const sampleRate = 8000; + const ctx = createMockAudioContext({ sampleRate }); + const freq = 1000; + const buf = createSineBuffer(ctx, freq, 0.001); + const data = buf.getChannelData(0); + // Verify each sample matches sin formula + for (let i = 0; i < data.length; i++) { + const expected = Math.sin((2 * Math.PI * freq * i) / sampleRate); + expect(data[i]).toBeCloseTo(expected, 5); + } + }); + + it("handles duration=0", () => { + const ctx = createMockAudioContext({ sampleRate: 44100 }); + const buf = createSineBuffer(ctx, 440, 0); + expect(buf.getChannelData(0).length).toBe(0); + }); + }); + + // ------------------------------------------------------------------------- + // createNoiseBuffer + // ------------------------------------------------------------------------- + describe("createNoiseBuffer", () => { + it("creates a buffer with values in [-1, 1]", () => { + const ctx = createMockAudioContext({ sampleRate: 44100 }); + const buf = createNoiseBuffer(ctx, 0.1); + const data = buf.getChannelData(0); + for (let i = 0; i < data.length; i++) { + expect(data[i]).toBeGreaterThanOrEqual(-1); + expect(data[i]).toBeLessThanOrEqual(1); + } + }); + + it("produces non-uniform data (not all same value)", () => { + const ctx = createMockAudioContext({ sampleRate: 44100 }); + const buf = createNoiseBuffer(ctx, 0.1); + const data = buf.getChannelData(0); + const uniqueValues = new Set(); + for (let i = 0; i < Math.min(data.length, 100); i++) { + uniqueValues.add(data[i]!); + } + expect(uniqueValues.size).toBeGreaterThan(1); + }); + }); + + // ------------------------------------------------------------------------- + // createClickBuffer + // ------------------------------------------------------------------------- + describe("createClickBuffer", () => { + it("applies exponential decay (start > end)", () => { + const ctx = createMockAudioContext({ sampleRate: 44100 }); + const buf = createClickBuffer(ctx, 440, 0.05); + const data = buf.getChannelData(0); + // The absolute amplitude near the start should be greater than near the end + const startMax = Math.max(...Array.from(data.subarray(0, 10)).map(Math.abs)); + const endMax = Math.max(...Array.from(data.subarray(data.length - 10)).map(Math.abs)); + expect(startMax).toBeGreaterThan(endMax); + }); + + it("matches exponential decay * sine formula", () => { + const sampleRate = 8000; + const ctx = createMockAudioContext({ sampleRate }); + const freq = 1000; + const duration = 0.01; + const buf = createClickBuffer(ctx, freq, duration); + const data = buf.getChannelData(0); + const length = data.length; + for (let i = 0; i < length; i++) { + const envelope = Math.exp((-5 * i) / length); + const expected = envelope * Math.sin((2 * Math.PI * freq * i) / sampleRate); + expect(data[i]).toBeCloseTo(expected, 5); + } + }); + }); +}); diff --git a/tests/waveform.test.ts b/tests/waveform.test.ts new file mode 100644 index 0000000..b14aa69 --- /dev/null +++ b/tests/waveform.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import { extractPeakPairs, extractPeaks, extractRMS } from "../src/waveform.js"; +import { createMockAudioBuffer } from "./helpers/audio-mocks.js"; + +/** Helper: create a buffer with known channel data. */ +function bufferWithData(data: Float32Array, channels = 1): AudioBuffer { + const allChannels = [data]; + for (let i = 1; i < channels; i++) { + allChannels.push(new Float32Array(data.length)); + } + return { + numberOfChannels: channels, + length: data.length, + sampleRate: 44100, + duration: data.length / 44100, + getChannelData: (ch: number) => allChannels[ch]!, + } as unknown as AudioBuffer; +} + +/** Helper: create a multi-channel buffer. */ +function multiChannelBuffer(channelDataArrays: Float32Array[]): AudioBuffer { + const length = channelDataArrays[0]?.length ?? 0; + return { + numberOfChannels: channelDataArrays.length, + length, + sampleRate: 44100, + duration: length / 44100, + getChannelData: (ch: number) => channelDataArrays[ch]!, + } as unknown as AudioBuffer; +} + +describe("waveform", () => { + // ------------------------------------------------------------------------- + // extractPeaks + // ------------------------------------------------------------------------- + describe("extractPeaks", () => { + it("returns correct peak values for known data", () => { + // 10 samples, resolution=5 → blockSize=2 + const data = new Float32Array([0.1, 0.5, -0.3, 0.8, 0.0, -0.2, 0.7, 0.1, -0.9, 0.4]); + const buf = bufferWithData(data); + const peaks = extractPeaks(buf, { resolution: 5 }); + // Block 0: [0.1, 0.5] → 0.5 + // Block 1: [-0.3, 0.8] → 0.8 + // Block 2: [0.0, -0.2] → 0.2 + // Block 3: [0.7, 0.1] → 0.7 + // Block 4: [-0.9, 0.4] → 0.9 + expect(peaks.length).toBe(5); + expect(peaks[0]).toBeCloseTo(0.5, 5); + expect(peaks[1]).toBeCloseTo(0.8, 5); + expect(peaks[2]).toBeCloseTo(0.2, 5); + expect(peaks[3]).toBeCloseTo(0.7, 5); + expect(peaks[4]).toBeCloseTo(0.9, 5); + }); + + it("uses default resolution of 200", () => { + const buf = createMockAudioBuffer(1, 44100); + const peaks = extractPeaks(buf); + expect(peaks.length).toBe(200); + }); + + it("returns all zeros for silent buffer", () => { + const data = new Float32Array(100); + const buf = bufferWithData(data); + const peaks = extractPeaks(buf, { resolution: 10 }); + expect(peaks.every((v) => v === 0)).toBe(true); + }); + + it("handles resolution > data.length without NaN", () => { + const data = new Float32Array([0.5, -0.3]); + const buf = bufferWithData(data); + // resolution=10 > data.length=2 → blockSize = floor(2/10) = 0 + const peaks = extractPeaks(buf, { resolution: 10 }); + // With blockSize=0, start===end for each block, so max stays 0 + expect(peaks.every((v) => !Number.isNaN(v))).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // extractPeakPairs + // ------------------------------------------------------------------------- + describe("extractPeakPairs", () => { + it("returns correct min/max pairs", () => { + const data = new Float32Array([0.1, 0.5, -0.3, 0.8, -0.9, 0.4, 0.7, 0.1]); + const buf = bufferWithData(data); + const pairs = extractPeakPairs(buf, { resolution: 4 }); + // Block 0: [0.1, 0.5] → {min: 0, max: 0.5} (init min=0, max=0) + expect(pairs[0]!.max).toBeCloseTo(0.5); + expect(pairs[0]!.min).toBeCloseTo(0); + // Block 1: [-0.3, 0.8] → {min: -0.3, max: 0.8} + expect(pairs[1]!.min).toBeCloseTo(-0.3); + expect(pairs[1]!.max).toBeCloseTo(0.8); + }); + + it("handles all-zero data", () => { + const data = new Float32Array(100); + const buf = bufferWithData(data); + const pairs = extractPeakPairs(buf, { resolution: 10 }); + for (const p of pairs) { + expect(p.min).toBe(0); + expect(p.max).toBe(0); + } + }); + }); + + // ------------------------------------------------------------------------- + // extractRMS + // ------------------------------------------------------------------------- + describe("extractRMS", () => { + it("computes correct RMS for known data", () => { + // 4 samples, resolution=2 → blockSize=2 + // Block 0: [1, 0] → sqrt((1+0)/2) = sqrt(0.5) + // Block 1: [0, 1] → sqrt((0+1)/2) = sqrt(0.5) + const data = new Float32Array([1, 0, 0, 1]); + const buf = bufferWithData(data); + const rms = extractRMS(buf, { resolution: 2 }); + expect(rms[0]).toBeCloseTo(Math.sqrt(0.5), 5); + expect(rms[1]).toBeCloseTo(Math.sqrt(0.5), 5); + }); + + it("returns 0 for silent buffer", () => { + const data = new Float32Array(100); + const buf = bufferWithData(data); + const rms = extractRMS(buf, { resolution: 10 }); + expect(rms.every((v) => v === 0)).toBe(true); + }); + + it("averages across all channels when channel=-1", () => { + const ch0 = new Float32Array([1, 0, 0, 0]); + const ch1 = new Float32Array([0, 0, 1, 0]); + const buf = multiChannelBuffer([ch0, ch1]); + const rms = extractRMS(buf, { resolution: 2, channel: -1 }); + // Block 0 ch0: sqrt(1/2), ch1: sqrt(0) → avg = sqrt(0.5)/2 + // Block 1 ch0: sqrt(0), ch1: sqrt(1/2) → avg = sqrt(0.5)/2 + const expected = Math.sqrt(0.5) / 2; + expect(rms[0]).toBeCloseTo(expected, 5); + expect(rms[1]).toBeCloseTo(expected, 5); + }); + + it("handles resolution > data.length without NaN (fixed: blockSize clamped to 1)", () => { + const data = new Float32Array([0.5, -0.3]); + const buf = bufferWithData(data); + const rms = extractRMS(buf, { resolution: 10 }); + for (const v of rms) { + expect(Number.isNaN(v)).toBe(false); + } + }); + + it("handles channel=-1 with 0-channel buffer (fixed: returns empty array)", () => { + const buf = { + numberOfChannels: 0, + length: 100, + sampleRate: 44100, + duration: 100 / 44100, + getChannelData: () => new Float32Array(100), + } as unknown as AudioBuffer; + const result = extractRMS(buf, { resolution: 10, channel: -1 }); + expect(result).toEqual([]); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index c57a0e7..c70a4e0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,13 +1,38 @@ +import { playwright } from "@vitest/browser-playwright"; import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - environment: "node", - include: ["tests/**/*.test.ts"], coverage: { include: ["src/**/*.ts"], exclude: ["src/index.ts", "src/types.ts"], }, + projects: [ + { + test: { + name: "unit", + include: ["tests/**/*.test.ts"], + exclude: ["tests/**/*.browser.test.ts"], + environment: "node", + }, + }, + { + test: { + name: "browser", + include: ["tests/**/*.browser.test.ts"], + browser: { + enabled: true, + headless: true, + provider: playwright({ + launchOptions: { + args: ["--autoplay-policy=no-user-gesture-required"], + }, + }), + instances: [{ browser: "chromium" }], + }, + }, + }, + ], }, });