第二批反馈:引擎改造(限幅/SoundTouch不变调变速/长按快进/跳句/iOS闪光降级) + 6游戏状态BGM与试玩测试全绿

This commit is contained in:
SoundGame Dev
2026-08-30 16:45:42 +08:00
parent cc0f8c008d
commit 9a915d62c0
1163 changed files with 6346 additions and 208 deletions
+118 -22
View File
@@ -32,15 +32,28 @@
* - settings.voice:voice 层按性别解析路径——play('voice/intro') 实际依次尝试
* `audio/voice/<gender>/intro.{ogg,m4a,mp3,wav}`,找不到回退平铺的
* `audio/voice/intro.*`(过渡期资产未分性别也不会炸)。游戏代码不用改。
* - settings.speed:voice 层 source.playbackRate(语速),从下一句开始生效。
* - settings.speed:voice 层语速,**变速不变调**——voice 走 SoundTouch 时间
* 拉伸管线(vendor/soundtouch.js,ScriptProcessorNode),从下一句开始生效。
* - settings.bgm:BGM 层增益;改动立即作用于进行中的 BGM(150ms 渐变)。
* 为 0 时不加载不播放 BGM(play('bgm/...') 记为"想播"直接跳过;
* 调回非 0 时若此前有想播的 BGM 会自动补播)。
* 调回非 0 时若此前有想播的 BGM 会自动补播——**状态 BGM 约定**:游戏按状态
* (菜单/探索/战斗/结算)直接 play('bgm/xxx') 切换即可,bgm=0 时切换只更新
* "想播",重新打开时引擎补播最后一个状态 BGM,游戏层无需特殊处理。
*
* **爆音防护**:master 链末端挂 DynamicsCompressorNode 限幅
* (threshold -6dB / ratio 12 / attack 3ms / release 250ms),sfx 总线基础
* 增益 0.8——多路叠加超 0dBFS 时压限而非硬削波。
*
* **长按快进**:voice 播放中长按屏幕(gesture.js 派发的 window 事件
* 'soundgame:longpress')→ 旁白临时 1.7 倍速(不变调),松开
* ('soundgame:longpressend')恢复 settings.speed。游戏代码零改动。
*/
import { settings } from './settings.js';
import { SoundTouch, SimpleFilter, WebAudioBufferSource, getWebAudioNode } from './vendor/soundtouch.js';
const EXT_FALLBACKS = ['.ogg', '.m4a', '.mp3', '.wav'];
const LONGPRESS_BOOST = 1.7; // 长按快进倍速
export class WebAudioEngine {
/**
@@ -75,11 +88,17 @@ export class WebAudioEngine {
this._cache = new Map(); // key -> Promise<AudioBuffer|null>(voice 的 key 含性别)
this._bgm = null; // { id, source, gain } 当前 BGM
this._bgmWanted = null; // { id, opts } 最近一次 BGM 请求(bgm=0 时只记不播)
this._voice = null; // { source, gain, stopped } 当前对白 token
this._voice = null; // 当前对白 token(SoundTouch 管线,见 _playVoice)
this._voiceQueue = []; // queue:true 时的等待队列
// 设置变更实时生效
settings.subscribe((_, changed) => this._onSettingsChange(changed));
// 长按快进:voice 播放中长按 → 临时 1.7 倍速(不变调),松开恢复设置语速
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('soundgame:longpress', () => this._voiceBoost(true));
window.addEventListener('soundgame:longpressend', () => this._voiceBoost(false));
}
}
/**
@@ -91,8 +110,15 @@ export class WebAudioEngine {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) throw new Error('Web Audio API 不可用');
this.ctx = new AC();
// master → 限幅器 → destination:多路叠加超 0dBFS 时压限而非硬削波
this._master = this.ctx.createGain();
this._master.connect(this.ctx.destination);
this._limiter = this.ctx.createDynamicsCompressor();
this._limiter.threshold.value = -6;
this._limiter.ratio.value = 12;
this._limiter.attack.value = 0.003;
this._limiter.release.value = 0.25;
this._master.connect(this._limiter);
this._limiter.connect(this.ctx.destination);
this._bgmPrefBus = this.ctx.createGain();
this._bgmPrefBus.gain.value = settings.get().bgm;
this._bgmPrefBus.connect(this._master);
@@ -101,6 +127,7 @@ export class WebAudioEngine {
this._voiceBus = this.ctx.createGain();
this._voiceBus.connect(this._master);
this._sfxBus = this.ctx.createGain();
this._sfxBus.gain.value = 0.8; // sfx 总线基础增益下调,多音叠加防过载
this._sfxBus.connect(this._master);
}
if (this.ctx.state !== 'running') await this.ctx.resume();
@@ -194,12 +221,28 @@ export class WebAudioEngine {
this._voiceQueue.length = 0;
const cur = this._voice;
if (!cur) return;
cur.stopped = true;
this._voice = null;
try { cur.source.stop(); } catch (_) { /* 已停止 */ }
this._killVoice(cur);
this._unduckBgm();
}
/**
* 跳过当前对白:停掉当前 voice 并**视为这句自然播完**——触发其
* onComplete(链播下一句)并继续等待队列。与 stopVoice 的差异:
* stopVoice 作废整条链,skipVoice 让链继续走。
*/
skipVoice() {
const cur = this._voice;
if (!cur) return;
this._voice = null;
this._killVoice(cur);
// 以下与 _playVoice 自然播完路径一致
if (this._voiceQueue.length === 0) this._unduckBgm();
if (cur.onComplete) cur.onComplete();
const next = this._voiceQueue.shift();
if (next) this._playVoice(next.id, next);
}
/** 停止 BGM + 对白(sfx 都很短,不主动停)。 */
stopAll() {
this.stopBgm(200);
@@ -322,6 +365,12 @@ export class WebAudioEngine {
}
}
/**
* voice 播放(SoundTouch 时间拉伸管线,变速不变调):
* WebAudioBufferSource → SimpleFilter(SoundTouch) → ScriptProcessorNode
* → gain → voiceBus。自然结束用定时器判定(源消耗位置 ÷ 当前 tempo),
* 支持中途改 tempo(长按快进)并随之修正结束时刻。
*/
async _playVoice(id, { volume = 1, onComplete, queue = false } = {}) {
if (queue && this._voice) {
this._voiceQueue.push({ id, volume, onComplete });
@@ -330,35 +379,82 @@ export class WebAudioEngine {
// 默认顶掉当前对白(不打 stopVoice() 以免清掉 queue 语义)
if (this._voice) {
const cur = this._voice;
cur.stopped = true;
this._voice = null;
try { cur.source.stop(); } catch (_) { /* 已停止 */ }
this._killVoice(cur);
}
const buf = await this._load(id);
if (!buf) { this._unduckBgm(); return; }
const source = this.ctx.createBufferSource();
source.buffer = buf;
source.playbackRate.value = settings.get().speed; // 语速(下一句生效)
const st = new SoundTouch();
st.tempo = settings.get().speed; // 语速(不变调,下一句生效)
const filter = new SimpleFilter(new WebAudioBufferSource(buf), st);
const node = getWebAudioNode(this.ctx, filter); // ScriptProcessorNode,接入即开播
const gain = this.ctx.createGain();
gain.gain.value = this.voiceVolume * volume;
source.connect(gain);
node.connect(gain);
gain.connect(this._voiceBus);
const token = { source, gain, stopped: false };
const token = {
node, gain, st, buf,
stopped: false,
onComplete,
posSec: 0, // 已消耗的源时长(秒,按 tempo 累计)
lastT: performance.now(),
endTimer: null,
};
this._voice = token;
this._duckBgm();
this._armVoiceEnd(token);
}
source.onended = () => {
if (this._voice !== token || token.stopped) return; // 被打断:不回调
this._voice = null;
if (this._voiceQueue.length === 0) this._unduckBgm();
if (onComplete) onComplete();
const next = this._voiceQueue.shift();
if (next) this._playVoice(next.id, next);
};
source.start();
/** 结算 voice 自然播完(onComplete 链 + 队列推进)。 */
_finishVoice(token) {
if (this._voice !== token || token.stopped) return; // 被打断:不回调
token.stopped = true;
this._voice = null;
this._killVoice(token);
if (this._voiceQueue.length === 0) this._unduckBgm();
if (token.onComplete) token.onComplete();
const next = this._voiceQueue.shift();
if (next) this._playVoice(next.id, next);
}
/** 按当前 tempo 排结束定时器;tempo 变化时重排。 */
_armVoiceEnd(token) {
const remainSec = Math.max(0.05, (token.buf.duration - token.posSec) / token.st.tempo);
token.lastT = performance.now();
token.endTimer = setTimeout(() => this._finishVoice(token), remainSec * 1000 + 120);
}
/** 累计源消耗位置(tempo 变速前调用,保持剩余时长估算准确)。 */
_updateVoicePos(token) {
const now = performance.now();
token.posSec += ((now - token.lastT) / 1000) * token.st.tempo;
token.lastT = now;
}
/** 终止 voice 播放(断开处理节点 + 清结束定时器),不触发任何回调。 */
_killVoice(token) {
token.stopped = true;
if (token.endTimer !== null) {
clearTimeout(token.endTimer);
token.endTimer = null;
}
try { token.node.disconnect(); } catch (_) { /* 已断开 */ }
token.node.onaudioprocess = null;
}
/** 长按快进:voice 播放中临时提速到 1.7(不变调),松开恢复设置语速。 */
_voiceBoost(on) {
const cur = this._voice;
if (!cur || cur.stopped) return;
const target = on ? LONGPRESS_BOOST : settings.get().speed;
if (cur.st.tempo === target) return;
this._updateVoicePos(cur);
cur.st.tempo = target;
if (cur.endTimer !== null) clearTimeout(cur.endTimer);
this._armVoiceEnd(cur);
}
async _playSfx(id, { volume = 1 } = {}) {
+60 -1
View File
@@ -2,6 +2,7 @@
* gesture.js — 6 手势识别(触摸 + 鼠标双支持),对齐 Android GestureEngine 行为。
*
* 手势集合:'up' | 'down' | 'left' | 'right' | 'tap' | 'doubletap'
* | 'longpress' | 'longpressend'
*
* 判定规则(与 Android 版 GestureConfig 一致):
* - 滑动:累积位移 >= swipeMinDistance(默认 64px ≈ 64dp@160dpi)
@@ -10,6 +11,12 @@
* - 单击/双击:位移 < tapSlop 视为点按;双击窗口 doubleTapWindow(默认 300ms)。
* **单击回调有约 300ms 延迟**(要等双击窗口关闭才能确定不是双击),
* 即时反馈类操作请绑定滑动或双击,单击只用于非即时操作。
* - **长按**:按住不动 >= longpressMs(默认 450ms)触发 'longpress',
* 松开触发 'longpressend';长按期间不触发 tap;位移超 tapSlop 取消
* (已激活的长按会先补发 'longpressend',之后滑动照常判定)。
* 长按触发/结束同时派发 window 事件 'soundgame:longpress' /
* 'soundgame:longpressend'——audio.js 内置监听:voice 播放中长按 =
* 旁白临时 1.7 倍速(不变调),松开恢复设置语速,游戏代码零改动。
*
* 用法:
* import { attachGestures } from '../engine/gesture.js';
@@ -32,8 +39,17 @@ export const DEFAULT_GESTURE_CONFIG = {
swipeMinVelocity: 400, // px/s,平均速度阈值(位移 / 拖动时长)
doubleTapWindow: 300, // ms,双击判定窗口(单击因此延迟 ~300ms 回调)
tapSlop: 24, // px,位移小于此值才算点按
longpressMs: 450, // ms,按住不动超过此值触发 longpress
};
/** 长按引擎事件:audio.js 监听它实现"长按=旁白临时倍速"。 */
function _dispatchLongpress(type) {
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function'
&& typeof CustomEvent === 'function') {
window.dispatchEvent(new CustomEvent(`soundgame:${type}`));
}
}
export function attachGestures(element, onGesture, config = {}) {
const cfg = { ...DEFAULT_GESTURE_CONFIG, ...config };
@@ -42,6 +58,10 @@ export function attachGestures(element, onGesture, config = {}) {
let startX = 0, startY = 0, startTime = 0;
let moved = false; // 位移是否已超 tapSlop(超过则本次不可能是点按)
// 长按状态
let longPressTimer = null;
let longPressActive = false;
// 点按状态
let lastTapTime = 0;
let pendingTapTimer = null;
@@ -56,22 +76,57 @@ export function attachGestures(element, onGesture, config = {}) {
}
}
function cancelLongPress() {
if (longPressTimer !== null) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
/** 长按已激活后收尾(松开/滑动取消):补发 longpressend。 */
function endLongPress() {
if (!longPressActive) return;
longPressActive = false;
_dispatchLongpress('longpressend');
onGesture('longpressend');
}
function handleDown(x, y) {
tracking = true;
moved = false;
startX = x;
startY = y;
startTime = performance.now();
cancelLongPress();
longPressTimer = setTimeout(() => {
longPressTimer = null;
if (!tracking || moved) return;
// 触发长按:作废等待中的单击
cancelPendingTap();
longPressActive = true;
_dispatchLongpress('longpress');
onGesture('longpress');
}, cfg.longpressMs);
}
function handleMove(x, y) {
if (!tracking) return;
if (Math.hypot(x - startX, y - startY) > cfg.tapSlop) moved = true;
if (Math.hypot(x - startX, y - startY) > cfg.tapSlop) {
moved = true;
cancelLongPress(); // 滑动取消长按判定
endLongPress(); // 已激活的长按被滑动终止(之后滑动照常判定)
}
}
function handleUp(x, y) {
if (!tracking) return;
tracking = false;
cancelLongPress();
if (longPressActive) {
endLongPress();
lastTapTime = 0;
return; // 长按后不触发 tap/doubletap
}
const dx = x - startX;
const dy = y - startY;
@@ -115,6 +170,8 @@ export function attachGestures(element, onGesture, config = {}) {
function handleCancel() {
tracking = false;
cancelLongPress();
endLongPress();
}
// ---- 触摸 ----
@@ -178,6 +235,8 @@ export function attachGestures(element, onGesture, config = {}) {
return function detach() {
cancelPendingTap();
cancelLongPress();
endLongPress();
element.removeEventListener('touchstart', onTouchStart, opts);
element.removeEventListener('touchmove', onTouchMove, opts);
element.removeEventListener('touchend', onTouchEnd, opts);
+55 -10
View File
@@ -15,8 +15,12 @@
* | beat | [8] | 节拍锚点(最轻) |
* | danger | [25,40,25,40,25] | 危险逼近 |
*
* 特性检测:'vibrate' in navigator。不支持的平台(iOS Safari 等)
* 所有调用静默 no-op,不抛异常。
* 特性检测:'vibrate' in navigator。支持的平台正常震动;
* **不支持的平台(iOS Safari 等)降级为视觉反馈**——全屏边框闪光覆盖层
* (#haptics-flash,样式在 style.css),不同震型对应不同颜色与闪烁次数:
* confirm 白·1 次 / error 红·2 次 / success 绿·1 次 / warning 橙·3 次 /
* swipe 白·极淡 1 次 / heartbeat 粉·2 次 / pickup 青·1 次 / hit 红·强 1 次 /
* beat 白·极淡 1 次 / danger 红·快 3 次。两者都不行的环境静默 no-op。
*
* 用法:
* import { vibrate } from '../engine/haptics.js';
@@ -41,26 +45,67 @@ export const HAPTIC_PATTERNS = {
danger: [25, 40, 25, 40, 25],
};
/** 当前环境是否支持震动(iOS Safari 为 false)。 */
/** 当前环境是否支持震动(iOS Safari 为 false,此时 vibrate() 走视觉降级)。 */
export const hapticsSupported =
typeof navigator !== 'undefined' && 'vibrate' in navigator;
/** 震型 → 视觉降级闪光参数:[颜色, 动画 class, 亮度] */
const FLASH_MAP = {
confirm: ['#ffffff', 'hp-once', 0.7],
error: ['#ff5252', 'hp-twice', 0.9],
success: ['#69f0ae', 'hp-once', 0.9],
warning: ['#ffb74d', 'hp-thrice', 0.9],
swipe: ['#ffffff', 'hp-once', 0.25],
heartbeat: ['#f48fb1', 'hp-twice', 0.7],
pickup: ['#80deea', 'hp-once', 0.7],
hit: ['#ff1744', 'hp-once', 1],
beat: ['#ffffff', 'hp-once', 0.15],
danger: ['#ff5252', 'hp-thrice', 0.9],
};
let flashEl = null;
/** 视觉降级:全屏边框闪光(覆盖层常驻 body,改 class 重触发动画)。 */
function _flash(patternName) {
if (typeof document === 'undefined') return;
const [color, anim, alpha] = FLASH_MAP[patternName] || FLASH_MAP.confirm;
if (!flashEl) {
flashEl = document.getElementById('haptics-flash');
if (!flashEl) {
flashEl = document.createElement('div');
flashEl.id = 'haptics-flash';
document.body.appendChild(flashEl);
}
}
flashEl.style.borderColor = color;
flashEl.style.boxShadow = `inset 0 0 32px ${color}`;
flashEl.style.setProperty('--hp-alpha', alpha);
flashEl.style.opacity = '';
flashEl.className = '';
void flashEl.offsetWidth; // 强制 reflow,重触发动画
flashEl.className = anim;
}
/**
* 触发震动。不支持时静默 no-op。
* 触发震动;不支持 vibrate 的平台自动降级为边框闪光。
* @param {keyof typeof HAPTIC_PATTERNS | number[]} pattern 预定义名称或毫秒数组
*/
export function vibrate(pattern) {
if (!hapticsSupported) return;
const p = typeof pattern === 'string' ? HAPTIC_PATTERNS[pattern] : pattern;
const name = typeof pattern === 'string' ? pattern : null;
const p = name ? HAPTIC_PATTERNS[name] : pattern;
if (!p) {
console.warn('[haptics] 未知震型:', pattern);
return;
}
try {
navigator.vibrate(p);
} catch (_) {
// 某些平台调用即抛(极少见)→ 静默
if (hapticsSupported) {
try {
navigator.vibrate(p);
return;
} catch (_) {
// 某些平台调用即抛(极少见)→ 落入视觉降级
}
}
_flash(name || 'confirm'); // 自定义数组无语义名,按 confirm 闪
}
export default vibrate;
+22 -1
View File
@@ -56,7 +56,7 @@ html, body {
position: absolute;
left: 0;
right: 0;
bottom: 18%;
top: 30%; /* 屏幕中间偏上,避开封面图底部文字区 */
text-align: center;
color: #fff;
font: 500 20px/1.5 system-ui, sans-serif;
@@ -69,3 +69,24 @@ html, body {
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}
/* ---- 震动视觉降级(haptics.js,iOS 等无 vibrate 平台) ----
全屏边框闪光覆盖层:震型 → 颜色/闪烁次数(hp-once/twice/thrice)。 */
#haptics-flash {
position: fixed;
inset: 0;
z-index: 90;
pointer-events: none;
opacity: 0;
border-style: solid;
border-width: 6px;
border-radius: 4px;
}
#haptics-flash.hp-once { animation: hp-flash 0.28s ease-out 1; }
#haptics-flash.hp-twice { animation: hp-flash 0.24s ease-out 2; }
#haptics-flash.hp-thrice { animation: hp-flash 0.2s ease-out 3; }
@keyframes hp-flash {
0% { opacity: 0; }
20% { opacity: var(--hp-alpha, 1); }
100% { opacity: 0; }
}
+794
View File
@@ -0,0 +1,794 @@
/*
* SoundTouch JS v0.3.0 audio processing library
* Copyright (c) Olli Parviainen
* Copyright (c) Ryan Berdeen
* Copyright (c) Jakub Fiala
* Copyright (c) Steve 'Cutter' Blades
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
class FifoSampleBuffer {
constructor() {
this._vector = new Float32Array();
this._position = 0;
this._frameCount = 0;
}
get vector() {
return this._vector;
}
get position() {
return this._position;
}
get startIndex() {
return this._position * 2;
}
get frameCount() {
return this._frameCount;
}
get endIndex() {
return (this._position + this._frameCount) * 2;
}
clear() {
this._vector.fill(0);
this._position = 0;
this._frameCount = 0;
}
put(numFrames) {
this._frameCount += numFrames;
}
putSamples(samples, position, numFrames = 0) {
position = position || 0;
const sourceOffset = position * 2;
if (!(numFrames >= 0)) {
numFrames = (samples.length - sourceOffset) / 2;
}
const numSamples = numFrames * 2;
this.ensureCapacity(numFrames + this._frameCount);
const destOffset = this.endIndex;
this.vector.set(samples.subarray(sourceOffset, sourceOffset + numSamples), destOffset);
this._frameCount += numFrames;
}
putBuffer(buffer, position, numFrames = 0) {
position = position || 0;
if (!(numFrames >= 0)) {
numFrames = buffer.frameCount - position;
}
this.putSamples(buffer.vector, buffer.position + position, numFrames);
}
receive(numFrames) {
if (!(numFrames >= 0) || numFrames > this._frameCount) {
numFrames = this.frameCount;
}
this._frameCount -= numFrames;
this._position += numFrames;
}
receiveSamples(output, numFrames = 0) {
const numSamples = numFrames * 2;
const sourceOffset = this.startIndex;
output.set(this._vector.subarray(sourceOffset, sourceOffset + numSamples));
this.receive(numFrames);
}
extract(output, position = 0, numFrames = 0) {
const sourceOffset = this.startIndex + position * 2;
const numSamples = numFrames * 2;
output.set(this._vector.subarray(sourceOffset, sourceOffset + numSamples));
}
ensureCapacity(numFrames = 0) {
const minLength = parseInt(numFrames * 2);
if (this._vector.length < minLength) {
const newVector = new Float32Array(minLength);
newVector.set(this._vector.subarray(this.startIndex, this.endIndex));
this._vector = newVector;
this._position = 0;
} else {
this.rewind();
}
}
ensureAdditionalCapacity(numFrames = 0) {
this.ensureCapacity(this._frameCount + numFrames);
}
rewind() {
if (this._position > 0) {
this._vector.set(this._vector.subarray(this.startIndex, this.endIndex));
this._position = 0;
}
}
}
class AbstractFifoSamplePipe {
constructor(createBuffers) {
if (createBuffers) {
this._inputBuffer = new FifoSampleBuffer();
this._outputBuffer = new FifoSampleBuffer();
} else {
this._inputBuffer = this._outputBuffer = null;
}
}
get inputBuffer() {
return this._inputBuffer;
}
set inputBuffer(inputBuffer) {
this._inputBuffer = inputBuffer;
}
get outputBuffer() {
return this._outputBuffer;
}
set outputBuffer(outputBuffer) {
this._outputBuffer = outputBuffer;
}
clear() {
this._inputBuffer.clear();
this._outputBuffer.clear();
}
}
class RateTransposer extends AbstractFifoSamplePipe {
constructor(createBuffers) {
super(createBuffers);
this.reset();
this._rate = 1;
}
set rate(rate) {
this._rate = rate;
}
reset() {
this.slopeCount = 0;
this.prevSampleL = 0;
this.prevSampleR = 0;
}
clear() {
super.clear();
this.reset();
}
clone() {
const result = new RateTransposer();
result.rate = this._rate;
return result;
}
process() {
const numFrames = this._inputBuffer.frameCount;
this._outputBuffer.ensureAdditionalCapacity(numFrames / this._rate + 1);
const numFramesOutput = this.transpose(numFrames);
this._inputBuffer.receive();
this._outputBuffer.put(numFramesOutput);
}
transpose(numFrames = 0) {
if (numFrames === 0) {
return 0;
}
const src = this._inputBuffer.vector;
const srcOffset = this._inputBuffer.startIndex;
const dest = this._outputBuffer.vector;
const destOffset = this._outputBuffer.endIndex;
let used = 0;
let i = 0;
while (this.slopeCount < 1.0) {
dest[destOffset + 2 * i] = (1.0 - this.slopeCount) * this.prevSampleL + this.slopeCount * src[srcOffset];
dest[destOffset + 2 * i + 1] = (1.0 - this.slopeCount) * this.prevSampleR + this.slopeCount * src[srcOffset + 1];
i = i + 1;
this.slopeCount += this._rate;
}
this.slopeCount -= 1.0;
if (numFrames !== 1) {
out: while (true) {
while (this.slopeCount > 1.0) {
this.slopeCount -= 1.0;
used = used + 1;
if (used >= numFrames - 1) {
break out;
}
}
const srcIndex = srcOffset + 2 * used;
dest[destOffset + 2 * i] = (1.0 - this.slopeCount) * src[srcIndex] + this.slopeCount * src[srcIndex + 2];
dest[destOffset + 2 * i + 1] = (1.0 - this.slopeCount) * src[srcIndex + 1] + this.slopeCount * src[srcIndex + 3];
i = i + 1;
this.slopeCount += this._rate;
}
}
this.prevSampleL = src[srcOffset + 2 * numFrames - 2];
this.prevSampleR = src[srcOffset + 2 * numFrames - 1];
return i;
}
}
class FilterSupport {
constructor(pipe) {
this._pipe = pipe;
}
get pipe() {
return this._pipe;
}
get inputBuffer() {
return this._pipe.inputBuffer;
}
get outputBuffer() {
return this._pipe.outputBuffer;
}
fillInputBuffer(
) {
throw new Error('fillInputBuffer() not overridden');
}
fillOutputBuffer(numFrames = 0) {
while (this.outputBuffer.frameCount < numFrames) {
const numInputFrames = 8192 * 2 - this.inputBuffer.frameCount;
this.fillInputBuffer(numInputFrames);
if (this.inputBuffer.frameCount < 8192 * 2) {
break;
}
this._pipe.process();
}
}
clear() {
this._pipe.clear();
}
}
const noop = function () {
return;
};
class SimpleFilter extends FilterSupport {
constructor(sourceSound, pipe, callback = noop) {
super(pipe);
this.callback = callback;
this.sourceSound = sourceSound;
this.historyBufferSize = 22050;
this._sourcePosition = 0;
this.outputBufferPosition = 0;
this._position = 0;
}
get position() {
return this._position;
}
set position(position) {
if (position > this._position) {
throw new RangeError('New position may not be greater than current position');
}
const newOutputBufferPosition = this.outputBufferPosition - (this._position - position);
if (newOutputBufferPosition < 0) {
throw new RangeError('New position falls outside of history buffer');
}
this.outputBufferPosition = newOutputBufferPosition;
this._position = position;
}
get sourcePosition() {
return this._sourcePosition;
}
set sourcePosition(sourcePosition) {
this.clear();
this._sourcePosition = sourcePosition;
}
onEnd() {
this.callback();
}
fillInputBuffer(numFrames = 0) {
const samples = new Float32Array(numFrames * 2);
const numFramesExtracted = this.sourceSound.extract(samples, numFrames, this._sourcePosition);
this._sourcePosition += numFramesExtracted;
this.inputBuffer.putSamples(samples, 0, numFramesExtracted);
}
extract(target, numFrames = 0) {
this.fillOutputBuffer(this.outputBufferPosition + numFrames);
const numFramesExtracted = Math.min(numFrames, this.outputBuffer.frameCount - this.outputBufferPosition);
this.outputBuffer.extract(target, this.outputBufferPosition, numFramesExtracted);
const currentFrames = this.outputBufferPosition + numFramesExtracted;
this.outputBufferPosition = Math.min(this.historyBufferSize, currentFrames);
this.outputBuffer.receive(Math.max(currentFrames - this.historyBufferSize, 0));
this._position += numFramesExtracted;
return numFramesExtracted;
}
handleSampleData(event) {
this.extract(event.data, 4096);
}
clear() {
super.clear();
this.outputBufferPosition = 0;
}
}
const USE_AUTO_SEQUENCE_LEN = 0;
const DEFAULT_SEQUENCE_MS = USE_AUTO_SEQUENCE_LEN;
const USE_AUTO_SEEKWINDOW_LEN = 0;
const DEFAULT_SEEKWINDOW_MS = USE_AUTO_SEEKWINDOW_LEN;
const DEFAULT_OVERLAP_MS = 8;
const _SCAN_OFFSETS = [[124, 186, 248, 310, 372, 434, 496, 558, 620, 682, 744, 806, 868, 930, 992, 1054, 1116, 1178, 1240, 1302, 1364, 1426, 1488, 0], [-100, -75, -50, -25, 25, 50, 75, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [-20, -15, -10, -5, 5, 10, 15, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [-4, -3, -2, -1, 1, 2, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]];
const AUTOSEQ_TEMPO_LOW = 0.25;
const AUTOSEQ_TEMPO_TOP = 4.0;
const AUTOSEQ_AT_MIN = 125.0;
const AUTOSEQ_AT_MAX = 50.0;
const AUTOSEQ_K = (AUTOSEQ_AT_MAX - AUTOSEQ_AT_MIN) / (AUTOSEQ_TEMPO_TOP - AUTOSEQ_TEMPO_LOW);
const AUTOSEQ_C = AUTOSEQ_AT_MIN - AUTOSEQ_K * AUTOSEQ_TEMPO_LOW;
const AUTOSEEK_AT_MIN = 25.0;
const AUTOSEEK_AT_MAX = 15.0;
const AUTOSEEK_K = (AUTOSEEK_AT_MAX - AUTOSEEK_AT_MIN) / (AUTOSEQ_TEMPO_TOP - AUTOSEQ_TEMPO_LOW);
const AUTOSEEK_C = AUTOSEEK_AT_MIN - AUTOSEEK_K * AUTOSEQ_TEMPO_LOW;
class Stretch extends AbstractFifoSamplePipe {
constructor(createBuffers) {
super(createBuffers);
this._quickSeek = true;
this.midBufferDirty = false;
this.midBuffer = null;
this.overlapLength = 0;
this.autoSeqSetting = true;
this.autoSeekSetting = true;
this._tempo = 1;
this.setParameters(44100, DEFAULT_SEQUENCE_MS, DEFAULT_SEEKWINDOW_MS, DEFAULT_OVERLAP_MS);
}
clear() {
super.clear();
this.clearMidBuffer();
}
clearMidBuffer() {
this.midBufferDirty = false;
this.midBuffer = null;
if (this.refMidBuffer) {
this.refMidBuffer.fill(0);
}
this.skipFract = 0;
}
setParameters(sampleRate, sequenceMs, seekWindowMs, overlapMs) {
if (sampleRate > 0) {
this.sampleRate = sampleRate;
}
if (overlapMs > 0) {
this.overlapMs = overlapMs;
}
if (sequenceMs > 0) {
this.sequenceMs = sequenceMs;
this.autoSeqSetting = false;
} else {
this.autoSeqSetting = true;
}
if (seekWindowMs > 0) {
this.seekWindowMs = seekWindowMs;
this.autoSeekSetting = false;
} else {
this.autoSeekSetting = true;
}
this.calculateSequenceParameters();
this.calculateOverlapLength(this.overlapMs);
this.tempo = this._tempo;
}
set tempo(newTempo) {
let intskip;
this._tempo = newTempo;
this.calculateSequenceParameters();
this.nominalSkip = this._tempo * (this.seekWindowLength - this.overlapLength);
this.skipFract = 0;
intskip = Math.floor(this.nominalSkip + 0.5);
this.sampleReq = Math.max(intskip + this.overlapLength, this.seekWindowLength) + this.seekLength;
}
get tempo() {
return this._tempo;
}
get inputChunkSize() {
return this.sampleReq;
}
get outputChunkSize() {
return this.overlapLength + Math.max(0, this.seekWindowLength - 2 * this.overlapLength);
}
calculateOverlapLength(overlapInMsec = 0) {
let newOvl;
newOvl = this.sampleRate * overlapInMsec / 1000;
newOvl = newOvl < 16 ? 16 : newOvl;
newOvl -= newOvl % 8;
this.overlapLength = newOvl;
this.refMidBuffer = new Float32Array(this.overlapLength * 2);
this.midBuffer = new Float32Array(this.overlapLength * 2);
}
checkLimits(x, mi, ma) {
return x < mi ? mi : x > ma ? ma : x;
}
calculateSequenceParameters() {
let seq;
let seek;
if (this.autoSeqSetting) {
seq = AUTOSEQ_C + AUTOSEQ_K * this._tempo;
seq = this.checkLimits(seq, AUTOSEQ_AT_MAX, AUTOSEQ_AT_MIN);
this.sequenceMs = Math.floor(seq + 0.5);
}
if (this.autoSeekSetting) {
seek = AUTOSEEK_C + AUTOSEEK_K * this._tempo;
seek = this.checkLimits(seek, AUTOSEEK_AT_MAX, AUTOSEEK_AT_MIN);
this.seekWindowMs = Math.floor(seek + 0.5);
}
this.seekWindowLength = Math.floor(this.sampleRate * this.sequenceMs / 1000);
this.seekLength = Math.floor(this.sampleRate * this.seekWindowMs / 1000);
}
set quickSeek(enable) {
this._quickSeek = enable;
}
clone() {
const result = new Stretch();
result.tempo = this._tempo;
result.setParameters(this.sampleRate, this.sequenceMs, this.seekWindowMs, this.overlapMs);
return result;
}
seekBestOverlapPosition() {
return this._quickSeek ? this.seekBestOverlapPositionStereoQuick() : this.seekBestOverlapPositionStereo();
}
seekBestOverlapPositionStereo() {
let bestOffset;
let bestCorrelation;
let correlation;
let i = 0;
this.preCalculateCorrelationReferenceStereo();
bestOffset = 0;
bestCorrelation = Number.MIN_VALUE;
for (; i < this.seekLength; i = i + 1) {
correlation = this.calculateCrossCorrelationStereo(2 * i, this.refMidBuffer);
if (correlation > bestCorrelation) {
bestCorrelation = correlation;
bestOffset = i;
}
}
return bestOffset;
}
seekBestOverlapPositionStereoQuick() {
let bestOffset;
let bestCorrelation;
let correlation;
let scanCount = 0;
let correlationOffset;
let tempOffset;
this.preCalculateCorrelationReferenceStereo();
bestCorrelation = Number.MIN_VALUE;
bestOffset = 0;
correlationOffset = 0;
tempOffset = 0;
for (; scanCount < 4; scanCount = scanCount + 1) {
let j = 0;
while (_SCAN_OFFSETS[scanCount][j]) {
tempOffset = correlationOffset + _SCAN_OFFSETS[scanCount][j];
if (tempOffset >= this.seekLength) {
break;
}
correlation = this.calculateCrossCorrelationStereo(2 * tempOffset, this.refMidBuffer);
if (correlation > bestCorrelation) {
bestCorrelation = correlation;
bestOffset = tempOffset;
}
j = j + 1;
}
correlationOffset = bestOffset;
}
return bestOffset;
}
preCalculateCorrelationReferenceStereo() {
let i = 0;
let context;
let temp;
for (; i < this.overlapLength; i = i + 1) {
temp = i * (this.overlapLength - i);
context = i * 2;
this.refMidBuffer[context] = this.midBuffer[context] * temp;
this.refMidBuffer[context + 1] = this.midBuffer[context + 1] * temp;
}
}
calculateCrossCorrelationStereo(mixingPosition, compare) {
const mixing = this._inputBuffer.vector;
mixingPosition += this._inputBuffer.startIndex;
let correlation = 0;
let i = 2;
const calcLength = 2 * this.overlapLength;
let mixingOffset;
for (; i < calcLength; i = i + 2) {
mixingOffset = i + mixingPosition;
correlation += mixing[mixingOffset] * compare[i] + mixing[mixingOffset + 1] * compare[i + 1];
}
return correlation;
}
overlap(overlapPosition) {
this.overlapStereo(2 * overlapPosition);
}
overlapStereo(inputPosition) {
const input = this._inputBuffer.vector;
inputPosition += this._inputBuffer.startIndex;
const output = this._outputBuffer.vector;
const outputPosition = this._outputBuffer.endIndex;
let i = 0;
let context;
let tempFrame;
const frameScale = 1 / this.overlapLength;
let fi;
let inputOffset;
let outputOffset;
for (; i < this.overlapLength; i = i + 1) {
tempFrame = (this.overlapLength - i) * frameScale;
fi = i * frameScale;
context = 2 * i;
inputOffset = context + inputPosition;
outputOffset = context + outputPosition;
output[outputOffset + 0] = input[inputOffset + 0] * fi + this.midBuffer[context + 0] * tempFrame;
output[outputOffset + 1] = input[inputOffset + 1] * fi + this.midBuffer[context + 1] * tempFrame;
}
}
process() {
let offset;
let temp;
let overlapSkip;
if (this.midBuffer === null) {
if (this._inputBuffer.frameCount < this.overlapLength) {
return;
}
this.midBuffer = new Float32Array(this.overlapLength * 2);
this._inputBuffer.receiveSamples(this.midBuffer, this.overlapLength);
}
while (this._inputBuffer.frameCount >= this.sampleReq) {
offset = this.seekBestOverlapPosition();
this._outputBuffer.ensureAdditionalCapacity(this.overlapLength);
this.overlap(Math.floor(offset));
this._outputBuffer.put(this.overlapLength);
temp = this.seekWindowLength - 2 * this.overlapLength;
if (temp > 0) {
this._outputBuffer.putBuffer(this._inputBuffer, offset + this.overlapLength, temp);
}
const start = this._inputBuffer.startIndex + 2 * (offset + this.seekWindowLength - this.overlapLength);
this.midBuffer.set(this._inputBuffer.vector.subarray(start, start + 2 * this.overlapLength));
this.skipFract += this.nominalSkip;
overlapSkip = Math.floor(this.skipFract);
this.skipFract -= overlapSkip;
this._inputBuffer.receive(overlapSkip);
}
}
}
const testFloatEqual = function (a, b) {
return (a > b ? a - b : b - a) > 1e-10;
};
class SoundTouch {
constructor() {
this.transposer = new RateTransposer(false);
this.stretch = new Stretch(false);
this._inputBuffer = new FifoSampleBuffer();
this._intermediateBuffer = new FifoSampleBuffer();
this._outputBuffer = new FifoSampleBuffer();
this._rate = 0;
this._tempo = 0;
this.virtualPitch = 1.0;
this.virtualRate = 1.0;
this.virtualTempo = 1.0;
this.calculateEffectiveRateAndTempo();
}
clear() {
this.transposer.clear();
this.stretch.clear();
}
clone() {
const result = new SoundTouch();
result.rate = this.rate;
result.tempo = this.tempo;
return result;
}
get rate() {
return this._rate;
}
set rate(rate) {
this.virtualRate = rate;
this.calculateEffectiveRateAndTempo();
}
set rateChange(rateChange) {
this._rate = 1.0 + 0.01 * rateChange;
}
get tempo() {
return this._tempo;
}
set tempo(tempo) {
this.virtualTempo = tempo;
this.calculateEffectiveRateAndTempo();
}
set tempoChange(tempoChange) {
this.tempo = 1.0 + 0.01 * tempoChange;
}
set pitch(pitch) {
this.virtualPitch = pitch;
this.calculateEffectiveRateAndTempo();
}
set pitchOctaves(pitchOctaves) {
this.pitch = Math.exp(0.69314718056 * pitchOctaves);
this.calculateEffectiveRateAndTempo();
}
set pitchSemitones(pitchSemitones) {
this.pitchOctaves = pitchSemitones / 12.0;
}
get inputBuffer() {
return this._inputBuffer;
}
get outputBuffer() {
return this._outputBuffer;
}
calculateEffectiveRateAndTempo() {
const previousTempo = this._tempo;
const previousRate = this._rate;
this._tempo = this.virtualTempo / this.virtualPitch;
this._rate = this.virtualRate * this.virtualPitch;
if (testFloatEqual(this._tempo, previousTempo)) {
this.stretch.tempo = this._tempo;
}
if (testFloatEqual(this._rate, previousRate)) {
this.transposer.rate = this._rate;
}
if (this._rate > 1.0) {
if (this._outputBuffer != this.transposer.outputBuffer) {
this.stretch.inputBuffer = this._inputBuffer;
this.stretch.outputBuffer = this._intermediateBuffer;
this.transposer.inputBuffer = this._intermediateBuffer;
this.transposer.outputBuffer = this._outputBuffer;
}
} else {
if (this._outputBuffer != this.stretch.outputBuffer) {
this.transposer.inputBuffer = this._inputBuffer;
this.transposer.outputBuffer = this._intermediateBuffer;
this.stretch.inputBuffer = this._intermediateBuffer;
this.stretch.outputBuffer = this._outputBuffer;
}
}
}
process() {
if (this._rate > 1.0) {
this.stretch.process();
this.transposer.process();
} else {
this.transposer.process();
this.stretch.process();
}
}
}
class WebAudioBufferSource {
constructor(buffer) {
this.buffer = buffer;
this._position = 0;
}
get dualChannel() {
return this.buffer.numberOfChannels > 1;
}
get position() {
return this._position;
}
set position(value) {
this._position = value;
}
extract(target, numFrames = 0, position = 0) {
this.position = position;
let left = this.buffer.getChannelData(0);
let right = this.dualChannel ? this.buffer.getChannelData(1) : this.buffer.getChannelData(0);
let i = 0;
for (; i < numFrames; i++) {
target[i * 2] = left[i + position];
target[i * 2 + 1] = right[i + position];
}
return Math.min(numFrames, left.length - position);
}
}
const getWebAudioNode = function (context, filter, sourcePositionCallback = noop, bufferSize = 4096) {
const node = context.createScriptProcessor(bufferSize, 2, 2);
const samples = new Float32Array(bufferSize * 2);
node.onaudioprocess = event => {
let left = event.outputBuffer.getChannelData(0);
let right = event.outputBuffer.getChannelData(1);
let framesExtracted = filter.extract(samples, bufferSize);
sourcePositionCallback(filter.sourcePosition);
if (framesExtracted === 0) {
filter.onEnd();
}
let i = 0;
for (; i < framesExtracted; i++) {
left[i] = samples[i * 2];
right[i] = samples[i * 2 + 1];
}
};
return node;
};
const pad = function (n, width, z) {
z = z || '0';
n = n + '';
return n.length >= width ? n : new Array(width - n.length + 1).join(z) + n;
};
const minsSecs = function (secs) {
const mins = Math.floor(secs / 60);
const seconds = secs - mins * 60;
return `${mins}:${pad(parseInt(seconds), 2)}`;
};
const onUpdate = function (sourcePosition) {
const currentTimePlayed = this.timePlayed;
const sampleRate = this.sampleRate;
this.sourcePosition = sourcePosition;
this.timePlayed = sourcePosition / sampleRate;
if (currentTimePlayed !== this.timePlayed) {
const timePlayed = new CustomEvent('play', {
detail: {
timePlayed: this.timePlayed,
formattedTimePlayed: this.formattedTimePlayed,
percentagePlayed: this.percentagePlayed
}
});
this._node.dispatchEvent(timePlayed);
}
};
class PitchShifter {
constructor(context, buffer, bufferSize, onEnd = noop) {
this._soundtouch = new SoundTouch();
const source = new WebAudioBufferSource(buffer);
this.timePlayed = 0;
this.sourcePosition = 0;
this._filter = new SimpleFilter(source, this._soundtouch, onEnd);
this._node = getWebAudioNode(context, this._filter, sourcePostion => onUpdate.call(this, sourcePostion), bufferSize);
this.tempo = 1;
this.rate = 1;
this.duration = buffer.duration;
this.sampleRate = context.sampleRate;
this.listeners = [];
}
get formattedDuration() {
return minsSecs(this.duration);
}
get formattedTimePlayed() {
return minsSecs(this.timePlayed);
}
get percentagePlayed() {
return 100 * this._filter.sourcePosition / (this.duration * this.sampleRate);
}
set percentagePlayed(perc) {
this._filter.sourcePosition = parseInt(perc * this.duration * this.sampleRate);
this.sourcePosition = this._filter.sourcePosition;
this.timePlayed = this.sourcePosition / this.sampleRate;
}
get node() {
return this._node;
}
set pitch(pitch) {
this._soundtouch.pitch = pitch;
}
set pitchSemitones(semitone) {
this._soundtouch.pitchSemitones = semitone;
}
set rate(rate) {
this._soundtouch.rate = rate;
}
set tempo(tempo) {
this._soundtouch.tempo = tempo;
}
connect(toNode) {
this._node.connect(toNode);
}
disconnect() {
this._node.disconnect();
}
on(eventName, cb) {
this.listeners.push({
name: eventName,
cb: cb
});
this._node.addEventListener(eventName, event => cb(event.detail));
}
off(eventName = null) {
let listeners = this.listeners;
if (eventName) {
listeners = listeners.filter(e => e.name === eventName);
}
listeners.forEach(e => {
this._node.removeEventListener(e.name, event => e.cb(event.detail));
});
}
}
export { AbstractFifoSamplePipe, PitchShifter, RateTransposer, SimpleFilter, SoundTouch, Stretch, WebAudioBufferSource, getWebAudioNode };
//# sourceMappingURL=soundtouch.js.map
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More