UNPKG

paulstretch

Version:

Extreme time-stretching for audio files in the browser using Web Audio API

1 lines 13.8 kB
!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.PaulStretch=e():t.PaulStretch=e()}(self,(()=>(()=>{"use strict";var t={d:(e,n)=>{for(var o in n)t.o(n,o)&&!t.o(e,o)&&Object.defineProperty(e,o,{enumerable:!0,get:n[o]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e)},e={};t.d(e,{default:()=>s});class n extends Error{constructor(t){super(t),this.name="PaulStretchError"}}class o{constructor(t){this.size=t,this.invSize=1/t}forward(t,e){const n=this.size;let o=0;for(let a=0;a<n-1;a++){a<o&&([t[a],t[o]]=[t[o],t[a]],[e[a],e[o]]=[e[o],e[a]]);let r=n>>1;for(;r<=o;)o-=r,r>>=1;o+=r}let a=2;for(;a<=n;){const o=a>>1,r=-2*Math.PI/a;for(let s=0;s<n;s+=a)for(let n=0;n<o;n++){const a=s+n,i=a+o,l=r*n,c=Math.cos(l),h=Math.sin(l),f=t[i]*c-e[i]*h,u=t[i]*h+e[i]*c;t[i]=t[a]-f,e[i]=e[a]-u,t[a]+=f,e[a]+=u}a<<=1}}inverse(t,e){for(let t=0;t<this.size;t++)e[t]=-e[t];this.forward(t,e);for(let n=0;n<this.size;n++)t[n]*=this.invSize,e[n]*=-this.invSize}}function a(t){const e=new Float32Array(t);let n=-1;const o=2/(t-1);for(let a=0;a<t;a++)e[a]=Math.pow(1-Math.pow(n,2),1.25),n+=o;return e}function r(t,e){const n=t[0].length,o=t.length;for(let a=0;a<n;a++)for(let n=0;n<o;n++)t[n][a]=t[n][a]*e[a]}const s=class{constructor(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};this.stretchFactor=t.stretchFactor||8,this.windowSize=t.windowSize||.25,this.useWorkers=!1!==t.useWorkers&&"undefined"!=typeof Worker,this.numWorkers=t.numWorkers||"undefined"!=typeof navigator&&navigator.hardwareConcurrency||4;const e=t.audioContext||("undefined"!=typeof window?window.AudioContext:null);if(!e)throw new n("AudioContext not available");this.audioContext=new e,this.workers=[],this.workerTasks=new Map,this.taskIdCounter=0,this.useWorkers&&this._initWorkers()}_initWorkers(){try{const t=this._getWorkerCode(),e=new Blob([t],{type:"application/javascript"}),n=URL.createObjectURL(e);for(let t=0;t<this.numWorkers;t++){const t=new Worker(n);t.onmessage=t=>this._handleWorkerMessage(t),t.onerror=t=>console.error("Worker error:",t),this.workers.push(t)}setTimeout((()=>URL.revokeObjectURL(n)),1e3)}catch(t){console.warn("Failed to initialize workers:",t),this.useWorkers=!1}}_getWorkerCode(){return"\n// FFT implementation\nclass FFT {\n constructor(size) {\n this.size = size;\n this.invSize = 1 / size;\n }\n \n forward(real, imag) {\n const n = this.size;\n \n // Bit reversal\n let j = 0;\n for (let i = 0; i < n - 1; i++) {\n if (i < j) {\n [real[i], real[j]] = [real[j], real[i]];\n [imag[i], imag[j]] = [imag[j], imag[i]];\n }\n let k = n >> 1;\n while (k <= j) {\n j -= k;\n k >>= 1;\n }\n j += k;\n }\n \n // Cooley-Tukey FFT\n let len = 2;\n while (len <= n) {\n const halfLen = len >> 1;\n const angleStep = -2 * Math.PI / len;\n for (let i = 0; i < n; i += len) {\n for (let j = 0; j < halfLen; j++) {\n const m = i + j;\n const n = m + halfLen;\n \n const angle = angleStep * j;\n const cos = Math.cos(angle);\n const sin = Math.sin(angle);\n \n const tReal = real[n] * cos - imag[n] * sin;\n const tImag = real[n] * sin + imag[n] * cos;\n \n real[n] = real[m] - tReal;\n imag[n] = imag[m] - tImag;\n real[m] += tReal;\n imag[m] += tImag;\n }\n }\n len <<= 1;\n }\n }\n \n inverse(real, imag) {\n // Conjugate\n for (let i = 0; i < this.size; i++) {\n imag[i] = -imag[i];\n }\n \n // Forward FFT\n this.forward(real, imag);\n \n // Conjugate and scale\n for (let i = 0; i < this.size; i++) {\n real[i] *= this.invSize;\n imag[i] *= -this.invSize;\n }\n }\n}\n\n// Process frames using sebpiq's exact algorithm\nfunction processFrames(params) {\n const {\n inputData,\n startFrame,\n numFrames,\n winSize,\n stretchFactor,\n winArray,\n taskId,\n channelIndex\n } = params;\n \n const halfWinSize = winSize / 2;\n const displacePos = halfWinSize / stretchFactor;\n const fft = new FFT(winSize);\n \n // Calculate output blocks\n const results = [];\n let inputPos = startFrame;\n const phaseArray = new Float32Array(halfWinSize + 1);\n \n for (let i = 0; i < numFrames; i++) {\n if (inputPos + winSize > inputData.length) break;\n \n // Create block for processing\n const blockIn = new Float32Array(winSize);\n \n // Fill input block\n for (let j = 0; j < winSize; j++) {\n blockIn[j] = inputData[Math.floor(inputPos) + j];\n }\n \n // Apply window to input\n for (let j = 0; j < winSize; j++) {\n blockIn[j] *= winArray[j];\n }\n \n // Phase randomization\n const real = new Float32Array(blockIn);\n const imag = new Float32Array(winSize);\n \n // Forward FFT\n fft.forward(real, imag);\n \n // Generate random phases\n for (let j = 0; j <= halfWinSize; j++) {\n phaseArray[j] = Math.random() * 2 * Math.PI;\n }\n \n // Apply new phases\n for (let j = 0; j <= halfWinSize; j++) {\n const amplitude = Math.sqrt(real[j] * real[j] + imag[j] * imag[j]);\n real[j] = amplitude * Math.cos(phaseArray[j]);\n imag[j] = amplitude * Math.sin(phaseArray[j]);\n }\n \n // Mirror for negative frequencies\n for (let j = 1; j < halfWinSize; j++) {\n real[winSize - j] = real[j];\n imag[winSize - j] = -imag[j];\n }\n \n // Inverse FFT\n fft.inverse(real, imag);\n \n // Apply window again and store result\n for (let j = 0; j < winSize; j++) {\n blockIn[j] = real[j] * winArray[j];\n }\n \n results.push({\n block: blockIn,\n inputPos: inputPos\n });\n \n inputPos += displacePos;\n }\n \n return results;\n}\n\n// Handle messages from main thread\nself.onmessage = function(e) {\n const { action, params } = e.data;\n \n if (action === 'processFrames') {\n try {\n const results = processFrames(params);\n \n // Convert results to transferable format\n const blocks = [];\n const positions = [];\n \n for (const result of results) {\n blocks.push(result.block);\n positions.push(result.inputPos);\n }\n \n self.postMessage({\n taskId: params.taskId,\n channelIndex: params.channelIndex,\n blocks: blocks,\n positions: positions,\n success: true\n });\n } catch (error) {\n self.postMessage({\n taskId: params.taskId,\n error: error.message,\n success: false\n });\n }\n }\n};\n "}_handleWorkerMessage(t){const{taskId:e,success:n,error:o}=t.data,a=this.workerTasks.get(e);a&&(this.workerTasks.delete(e),n?a.resolve(t.data):a.reject(new Error(o)))}_sendToWorker(t,e){return new Promise(((n,o)=>{const a=this.taskIdCounter++;this.workerTasks.set(a,{resolve:n,reject:o}),t.postMessage({action:"processFrames",params:{...e,taskId:a}})}))}async loadAudio(t){if(!t)throw new n("Invalid input");try{let e;if(t instanceof File||t instanceof Blob)e=await t.arrayBuffer();else{if("string"!=typeof t)throw new n("Input must be a File, Blob, or URL string");{const n=await fetch(t);e=await n.arrayBuffer()}}return await this.audioContext.decodeAudioData(e)}catch(t){throw new n(`Failed to load audio: ${t.message}`)}}async stretch(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null;if(!(t&&t instanceof AudioBuffer))throw new n("Invalid audio buffer");const o=t.sampleRate,a=(t.numberOfChannels,Math.floor(this.windowSize*o));return Math.pow(2,Math.ceil(Math.log2(a))),this.useWorkers&&this.workers.length>0?this._stretchParallel(t,e):this._stretchSingleThread(t,e)}async _stretchParallel(t,e){const n=t.sampleRate,o=t.numberOfChannels,r=Math.floor(this.windowSize*n),s=Math.pow(2,Math.ceil(Math.log2(r))),i=s/2,l=i/this.stretchFactor,c=Math.floor(t.length*this.stretchFactor),h=this.audioContext.createBuffer(o,c,n),f=a(s),u=Math.floor((t.length-s)/l),d=Math.max(1,Math.floor(u/(3*this.workers.length))),w=[];for(let e=0;e<o;e++){const n=t.getChannelData(e);let o=0;for(;o<u;){const t=Math.min(d,u-o);if(t<=0)break;w.push({inputData:n,startFrame:o*l,numFrames:t,winSize:s,stretchFactor:this.stretchFactor,winArray:f,channelIndex:e}),o+=t}}const p=[];let m=0,g=0;const k=w.length;for(const t of w){const n=this._sendToWorker(this.workers[m],t).then((t=>(g++,e&&e(g/k,0,o),t)));p.push(n),m=(m+1)%this.workers.length}const j=await Promise.all(p),y=new Map;for(let t=0;t<o;t++)y.set(t,[]);for(const t of j)y.get(t.channelIndex).push(t);for(let t=0;t<o;t++){const e=y.get(t),n=h.getChannelData(t),o=new Float32Array(s),a=[];for(const t of e)for(let e=0;e<t.blocks.length;e++)a.push({block:t.blocks[e],inputPos:t.positions[e]});a.sort(((t,e)=>t.inputPos-e.inputPos));let r=0;for(const{block:t}of a){for(let e=0;e<i&&r+e<c;e++)n[r+e]+=t[e]+o[i+e];for(let e=0;e<s;e++)o[e]=t[e];r+=i}let l=0;for(let t=0;t<n.length;t++)l=Math.max(l,Math.abs(n[t]));if(l>0){const t=.95/l;for(let e=0;e<n.length;e++)n[e]*=t}}return h}async _stretchSingleThread(t,e){const n=t.sampleRate,s=t.numberOfChannels,i=Math.floor(this.windowSize*n),l=Math.pow(2,Math.ceil(Math.log2(i))),c=l/2,h=Math.floor(t.length*this.stretchFactor),f=this.audioContext.createBuffer(s,h,n),u=a(l),d=function(t){const e=t/2,n=new o(t);return function(o,a){const r=new Float32Array(o),s=new Float32Array(t);n.forward(r,s);for(let t=0;t<=e;t++){const e=Math.sqrt(r[t]*r[t]+s[t]*s[t]);r[t]=e*Math.cos(a[t]),s[t]=e*Math.sin(a[t])}for(let n=1;n<e;n++)r[t-n]=r[n],s[t-n]=-s[n];n.inverse(r,s);for(let e=0;e<t;e++)o[e]=r[e]}}(l),w=[];for(let e=0;e<s;e++)w.push(t.getChannelData(e));const p=c/this.stretchFactor;let m=0,g=0,k=0;const j=Math.floor((t.length-l)/p),y=[],b=[],F=new Float32Array(c+1);for(let t=0;t<s;t++)y.push(new Float32Array(l)),b.push(new Float32Array(l));for(;m+l<=t.length;){for(let t=0;t<s;t++)for(let e=0;e<l;e++)y[t][e]=w[t][Math.floor(m)+e];r(y,u);for(let t=0;t<s;t++){for(let t=0;t<=c;t++)F[t]=2*Math.random()*Math.PI;d(y[t],F)}r(y,u);for(let t=0;t<s;t++){const e=f.getChannelData(t);for(let n=0;n<c&&g+n<h;n++)e[g+n]+=y[t][n]+b[t][c+n];for(let e=0;e<l;e++)b[t][e]=y[t][e]}if(m+=p,g+=c,k++,e&&k%100==0){const t=k/j;isFinite(t)&&t>=0&&t<=1&&e(t,0,s)}}for(let t=0;t<s;t++){const e=f.getChannelData(t);let n=0;for(let t=0;t<e.length;t++)n=Math.max(n,Math.abs(e[t]));if(n>0){const t=.95/n;for(let n=0;n<e.length;n++)e[n]*=t}}return f}async toBlob(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"audio/wav";if(!(t&&t instanceof AudioBuffer))throw new n("Invalid audio buffer");try{const n=t.length,o=t.numberOfChannels,a=t.sampleRate,r=2*o,s=n*r,i=44+s,l=new ArrayBuffer(i),c=new DataView(l);let h=0;const f=t=>{for(let e=0;e<t.length;e++)c.setUint8(h++,t.charCodeAt(e))},u=t=>{c.setUint16(h,t,!0),h+=2},d=t=>{c.setUint32(h,t,!0),h+=4};f("RIFF"),d(i-8),f("WAVE"),f("fmt "),d(16),u(1),u(o),d(a),d(a*r),u(r),u(16),f("data"),d(s);const w=[];for(let e=0;e<o;e++)w.push(t.getChannelData(e));for(let t=0;t<n;t++)for(let e=0;e<o;e++){let n,o=w[e][t];o=Math.max(-1,Math.min(1,o)),n=o<0?Math.floor(32768*o):Math.floor(32767*o),c.setInt16(h,n,!0),h+=2}return new Blob([l],{type:e})}catch(t){throw new n(`Failed to create blob: ${t.message}`)}}async toUrl(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"audio/wav";const n=await this.toBlob(t,e);return URL.createObjectURL(n)}async play(t){if(!(t&&t instanceof AudioBuffer))throw new n("Invalid audio buffer");try{"suspended"===this.audioContext.state&&await this.audioContext.resume();const e=this.audioContext.createBufferSource();return e.buffer=t,e.connect(this.audioContext.destination),e.start(0),new Promise((t=>{e.onended=t}))}catch(t){throw new n(`Failed to play audio: ${t.message}`)}}async download(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"stretched-audio.wav",o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"audio/wav";const a=await this.toBlob(t,o);if("undefined"==typeof window)throw new n("Download is only available in browser environment");{const t=URL.createObjectURL(a),n=document.createElement("a");n.href=t,n.download=e,document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(t)}}async processAndPlay(t){const e=await this.loadAudio(t),n=await this.stretch(e);return await this.play(n),n}async processAndDownload(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"stretched-audio.wav",n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"audio/wav";const o=await this.loadAudio(t),a=await this.stretch(o);return await this.download(a,e,n),a}dispose(){for(const t of this.workers)t.terminate();this.workers=[],this.workerTasks.clear()}};return e.default})()));