├── .gitignore ├── .npmrc ├── .travis.yml ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── index.test.ts └── index.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | pkg 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry = "https://registry.npmjs.org" 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | before_install: 3 | - npm i -g npm@6.8 4 | cache: npm 5 | node_js: 6 | - "10" 7 | script: 8 | - npm test 9 | - npm run test:pack 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## SoundTouch-TS 2 | 3 | [![Build Status](https://travis-ci.org/kirbysayshi/soundtouch-ts.svg?branch=master)](https://travis-ci.org/kirbysayshi/soundtouch-ts) 4 | 5 | A port of a port to TypeScript. Pitchshift and Timestretch in JS/TS. This library is LGPL2.1 due to [SoundTouch](https://gitlab.com/soundtouch/soundtouch). A more tested port of this library, with more utilities, is available in [soundtouch-js](https://github.com/cutterbl/SoundTouchJS). This TS port exists because I wanted the types, and didn't know soundtouch-js existed until recently. 6 | 7 | However, as long as you allow a user, at runtime, to swap out this library, it should fall under section 6b of https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html. But JS/TS and LGPL are legally ambiguous, so use at your own risk. 8 | 9 | ## Usage 10 | 11 | ```ts 12 | // It's TS, but JS is fine too! 13 | import { SoundTouch } from "soundtouch-ts"; 14 | const st = new SoundTouch(44100); 15 | 16 | const tempo = 0.5; 17 | 18 | // Audio will take 2x as long to play with no pitch changes 19 | st.tempo = tempo; 20 | 21 | const ctx = new AudioContext(); 22 | 23 | fetch("http://test-audio.somewhere.mp3") 24 | .then(res => res.arrayBuffer()) 25 | .then(ab => ctx.decodeAudio(ab)) 26 | .then(ab => { 27 | const interleaved = asInterleaved(ab); 28 | st.inputBuffer.putSamples(interleaved); 29 | st.process(); 30 | 31 | const channels = 2; 32 | const receiver = new Float32Array((channels * ab.length) / tempo); 33 | 34 | const requested = 256; 35 | let received = 0; 36 | while (st.outputBuffer.frameCount) { 37 | const queued = st.frameCount; 38 | st.process(); 39 | st.outputBuffer.receiveSamples(receiver.subarray(received), requested); 40 | const remaining = st.outputBuffer.frameCount; 41 | received = (queued - remaining) * channels; 42 | } 43 | 44 | const audio = asPlanar(receiver, 44100, 2); 45 | const abnode = new AudioBufferSourceNode(ctx, { buffer: audio }); 46 | ctx.destination.connect(abnode); 47 | abnode.start(); 48 | }); 49 | 50 | function asInterleaved(ab: AudioBuffer): Float32Array { 51 | const channels = ab.numberOfChannels; 52 | const output = new Float32Array(channels * ab.length); 53 | for (let i = 0; i < ab.length; i++) { 54 | for (let c = 0; c < channels; c++) { 55 | const chan = ab.getChannelData(c); 56 | output[i * channels + c] = chan[i]; 57 | } 58 | } 59 | return output; 60 | } 61 | 62 | function asPlanar( 63 | buffer: Float32Array, 64 | sampleRate: number, 65 | channels: number = 2 66 | ): AudioBuffer { 67 | const channelLength = Math.floor(buffer.length / channels); 68 | const output = new AudioBuffer({ 69 | numberOfChannels: channels, 70 | length: channelLength, 71 | sampleRate: sampleRate 72 | }); 73 | 74 | for (let c = 0; c < channels; c++) { 75 | const chan = output.getChannelData(c); 76 | for (let i = 0; i < channelLength; i++) { 77 | chan[i] = buffer[i * channels + c]; 78 | } 79 | } 80 | 81 | return output; 82 | } 83 | ``` 84 | 85 | ## Publishing 86 | 87 | ```sh 88 | $ npm version [xxx] 89 | $ npx pack build && pushd pkg && npm publish 90 | $ git push origin HEAD --tags 91 | ``` 92 | 93 | ## History 94 | 95 | This port was modified from the following: 96 | 97 | - Original Port (LGPL 2.1): https://github.com/also/soundtouch-js/tree/master/src/js 98 | - Modularized / Expanded (MIT): https://github.com/jakubfiala/soundtouch-js 99 | - Modified and included in a UI (MIT): https://github.com/ZVK/stretcher (http://zackzukowski.com/TAP-audio-player/) 100 | - Converted to TypeScript: [src/index.ts](src/index.ts). 101 | 102 | Changes from the original: 103 | 104 | - Conversion to ES6 (removal of prototypes), then TypeScript (addition of signature types and removal of implicity `any`). 105 | - Removal of various Web Audio helpers and APIs, focusing on the core SoundTouch API. 106 | 107 | ## License 108 | 109 | [GNU Lesser General Public Library, version 2.1](https://www.gnu.org/licenses/lgpl-2.1.en.html) 110 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node" 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "soundtouch-ts", 3 | "version": "1.1.1", 4 | "description": "A TypeScript conversion of SoundTouchJS", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "test:pack": "pack build" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/kirbysayshi/soundtouch-ts.git" 13 | }, 14 | "publishConfig": { 15 | "registry": "https://registry.npmjs.org" 16 | }, 17 | "keywords": [ 18 | "pitch", 19 | "timestretch", 20 | "TypeScript" 21 | ], 22 | "author": "Drew Petersen ", 23 | "license": "LGPL-2.1", 24 | "bugs": { 25 | "url": "https://github.com/kirbysayshi/soundtouch-ts/issues" 26 | }, 27 | "homepage": "https://github.com/kirbysayshi/soundtouch-ts#readme", 28 | "@pika/pack": { 29 | "pipeline": [ 30 | [ 31 | "@pika/plugin-ts-standard-pkg", 32 | { 33 | "exclude": [ 34 | "*.test.ts" 35 | ] 36 | } 37 | ], 38 | [ 39 | "@pika/plugin-build-node" 40 | ], 41 | [ 42 | "@pika/plugin-build-web" 43 | ] 44 | ] 45 | }, 46 | "husky": { 47 | "hooks": { 48 | "pre-commit": "pretty-quick --staged" 49 | } 50 | }, 51 | "devDependencies": { 52 | "@pika/pack": "^0.3.3", 53 | "@pika/plugin-build-node": "^0.3.12", 54 | "@pika/plugin-build-web": "^0.3.12", 55 | "@pika/plugin-ts-standard-pkg": "^0.3.12", 56 | "@types/jest": "^24.0.6", 57 | "husky": "^1.3.1", 58 | "jest": "^24.1.0", 59 | "prettier": "^1.16.4", 60 | "pretty-quick": "^1.10.0", 61 | "ts-jest": "^24.0.0", 62 | "typescript": "^3.3.3333" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { SoundTouch } from "."; 2 | 3 | test("Basic API", () => { 4 | const st = new SoundTouch(44100); 5 | expect(st).toBeTruthy(); 6 | expect(st.virtualPitch).toBe(1); 7 | }); 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SoundTouch JS audio processing library 3 | * Copyright (c) Olli Parviainen 4 | * Copyright (c) Ryan Berdeen 5 | * Copyright (c) Zach Zukowski 6 | * 7 | * This library is free software; you can redistribute it and/or 8 | * modify it under the terms of the GNU Lesser General Public 9 | * License as published by the Free Software Foundation; either 10 | * version 2.1 of the License, or (at your option) any later version. 11 | * 12 | * This library is distributed in the hope that it will be useful, 13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 15 | * Lesser General License for more details. 16 | * 17 | * You should have received a copy of the GNU Lesser General Public 18 | * License along with this library; if not, write to the Free Software 19 | * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 20 | */ 21 | 22 | /** 23 | * Giving this value for the sequence length sets automatic parameter value 24 | * according to tempo setting (recommended) 25 | */ 26 | const USE_AUTO_SEQUENCE_LEN = 0; 27 | 28 | /** 29 | * Default length of a single processing sequence, in milliseconds. This determines to how 30 | * long sequences the original sound is chopped in the time-stretch algorithm. 31 | * 32 | * The larger this value is, the lesser sequences are used in processing. In principle 33 | * a bigger value sounds better when slowing down tempo, but worse when increasing tempo 34 | * and vice versa. 35 | * 36 | * Increasing this value reduces computational burden and vice versa. 37 | */ 38 | //var DEFAULT_SEQUENCE_MS = 130 39 | const DEFAULT_SEQUENCE_MS = USE_AUTO_SEQUENCE_LEN; 40 | 41 | /** 42 | * Giving this value for the seek window length sets automatic parameter value 43 | * according to tempo setting (recommended) 44 | */ 45 | const USE_AUTO_SEEKWINDOW_LEN = 0; 46 | 47 | /** 48 | * Seeking window default length in milliseconds for algorithm that finds the best possible 49 | * overlapping location. This determines from how wide window the algorithm may look for an 50 | * optimal joining location when mixing the sound sequences back together. 51 | * 52 | * The bigger this window setting is, the higher the possibility to find a better mixing 53 | * position will become, but at the same time large values may cause a "drifting" artifact 54 | * because consequent sequences will be taken at more uneven intervals. 55 | * 56 | * If there's a disturbing artifact that sounds as if a constant frequency was drifting 57 | * around, try reducing this setting. 58 | * 59 | * Increasing this value increases computational burden and vice versa. 60 | */ 61 | //var DEFAULT_SEEKWINDOW_MS = 25; 62 | const DEFAULT_SEEKWINDOW_MS = USE_AUTO_SEEKWINDOW_LEN; 63 | 64 | /** 65 | * Overlap length in milliseconds. When the chopped sound sequences are mixed back together, 66 | * to form a continuous sound stream, this parameter defines over how long period the two 67 | * consecutive sequences are let to overlap each other. 68 | * 69 | * This shouldn't be that critical parameter. If you reduce the DEFAULT_SEQUENCE_MS setting 70 | * by a large amount, you might wish to try a smaller value on this. 71 | * 72 | * Increasing this value increases computational burden and vice versa. 73 | */ 74 | const DEFAULT_OVERLAP_MS = 8; 75 | 76 | // Table for the hierarchical mixing position seeking algorithm 77 | // prettier-ignore 78 | const _SCAN_OFFSETS = [ 79 | [ 124, 186, 248, 310, 372, 434, 496, 558, 620, 682, 744, 806, 80 | 868, 930, 992, 1054, 1116, 1178, 1240, 1302, 1364, 1426, 1488, 0], 81 | [-100, -75, -50, -25, 25, 50, 75, 100, 0, 0, 0, 0, 82 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 83 | [ -20, -15, -10, -5, 5, 10, 15, 20, 0, 0, 0, 0, 84 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 85 | [ -4, -3, -2, -1, 1, 2, 3, 4, 0, 0, 0, 0, 86 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]; 87 | 88 | // Adjust tempo param according to tempo, so that variating processing sequence length is used 89 | // at varius tempo settings, between the given low...top limits 90 | const AUTOSEQ_TEMPO_LOW = 0.5; // auto setting low tempo range (-50%) 91 | const AUTOSEQ_TEMPO_TOP = 2.0; // auto setting top tempo range (+100%) 92 | 93 | // sequence-ms setting values at above low & top tempo 94 | const AUTOSEQ_AT_MIN = 125.0; 95 | const AUTOSEQ_AT_MAX = 50.0; 96 | const AUTOSEQ_K = 97 | (AUTOSEQ_AT_MAX - AUTOSEQ_AT_MIN) / (AUTOSEQ_TEMPO_TOP - AUTOSEQ_TEMPO_LOW); 98 | const AUTOSEQ_C = AUTOSEQ_AT_MIN - AUTOSEQ_K * AUTOSEQ_TEMPO_LOW; 99 | 100 | // seek-window-ms setting values at above low & top tempo 101 | const AUTOSEEK_AT_MIN = 25.0; 102 | const AUTOSEEK_AT_MAX = 15.0; 103 | const AUTOSEEK_K = 104 | (AUTOSEEK_AT_MAX - AUTOSEEK_AT_MIN) / (AUTOSEQ_TEMPO_TOP - AUTOSEQ_TEMPO_LOW); 105 | const AUTOSEEK_C = AUTOSEEK_AT_MIN - AUTOSEEK_K * AUTOSEQ_TEMPO_LOW; 106 | 107 | function testFloatEqual(a: number, b: number) { 108 | return (a > b ? a - b : b - a) > 1e-10; 109 | } 110 | 111 | class AbstractFifoSamplePipe { 112 | inputBuffer: FifoSampleBuffer | null; 113 | outputBuffer: FifoSampleBuffer | null; 114 | 115 | constructor(createBuffers: boolean) { 116 | if (createBuffers) { 117 | this.inputBuffer = new FifoSampleBuffer(); 118 | this.outputBuffer = new FifoSampleBuffer(); 119 | } else { 120 | this.inputBuffer = this.outputBuffer = null; 121 | } 122 | } 123 | 124 | clear() { 125 | if (this.inputBuffer) this.inputBuffer.clear(); 126 | if (this.outputBuffer) this.outputBuffer.clear(); 127 | } 128 | } 129 | 130 | class RateTransposer extends AbstractFifoSamplePipe { 131 | rate: number; 132 | 133 | slopeCount: number = 0; 134 | prevSampleL: number = 0; 135 | prevSampleR: number = 0; 136 | 137 | constructor(createBuffers: boolean) { 138 | super(createBuffers); 139 | this._reset(); 140 | this.rate = 1; 141 | } 142 | 143 | _reset() { 144 | this.slopeCount = 0; 145 | this.prevSampleL = 0; 146 | this.prevSampleR = 0; 147 | } 148 | 149 | process() { 150 | // TODO aa filter 151 | const numFrames = this.inputBuffer!.frameCount; 152 | this.outputBuffer!.ensureAdditionalCapacity(numFrames / this.rate + 1); 153 | const numFramesOutput = this._transpose(numFrames); 154 | this.inputBuffer!.receive(); 155 | this.outputBuffer!.put(numFramesOutput); 156 | } 157 | 158 | _transpose(numFrames: number) { 159 | if (numFrames === 0) { 160 | return 0; // No work. 161 | } 162 | 163 | const src = this.inputBuffer!.vector; 164 | const srcOffset = this.inputBuffer!.startIndex; 165 | 166 | const dest = this.outputBuffer!.vector; 167 | const destOffset = this.outputBuffer!.endIndex; 168 | 169 | let used = 0; 170 | let i = 0; 171 | 172 | while (this.slopeCount < 1.0) { 173 | dest[destOffset + 2 * i] = 174 | (1.0 - this.slopeCount) * this.prevSampleL + 175 | this.slopeCount * src[srcOffset]; 176 | dest[destOffset + 2 * i + 1] = 177 | (1.0 - this.slopeCount) * this.prevSampleR + 178 | this.slopeCount * src[srcOffset + 1]; 179 | i++; 180 | this.slopeCount += this.rate; 181 | } 182 | 183 | this.slopeCount -= 1.0; 184 | 185 | if (numFrames != 1) { 186 | out: while (true) { 187 | while (this.slopeCount > 1.0) { 188 | this.slopeCount -= 1.0; 189 | used++; 190 | if (used >= numFrames - 1) { 191 | break out; 192 | } 193 | } 194 | 195 | const srcIndex = srcOffset + 2 * used; 196 | dest[destOffset + 2 * i] = 197 | (1.0 - this.slopeCount) * src[srcIndex] + 198 | this.slopeCount * src[srcIndex + 2]; 199 | dest[destOffset + 2 * i + 1] = 200 | (1.0 - this.slopeCount) * src[srcIndex + 1] + 201 | this.slopeCount * src[srcIndex + 3]; 202 | 203 | i++; 204 | this.slopeCount += this.rate; 205 | } 206 | } 207 | 208 | this.prevSampleL = src[srcOffset + 2 * numFrames - 2]; 209 | this.prevSampleR = src[srcOffset + 2 * numFrames - 1]; 210 | 211 | return i; 212 | } 213 | } 214 | 215 | class FifoSampleBuffer { 216 | private _vector = new Float32Array(0); 217 | private _position = 0; 218 | private _frameCount = 0; 219 | 220 | constructor() {} 221 | 222 | get vector() { 223 | return this._vector; 224 | } 225 | 226 | get position() { 227 | return this._position; 228 | } 229 | 230 | get startIndex() { 231 | return this._position * 2; 232 | } 233 | 234 | get frameCount() { 235 | return this._frameCount; 236 | } 237 | 238 | get endIndex() { 239 | return (this._position + this._frameCount) * 2; 240 | } 241 | 242 | clear(frameCount: number = -1) { 243 | this.receive(frameCount); 244 | this.rewind(); 245 | } 246 | 247 | put(numFrames: number) { 248 | this._frameCount += numFrames; 249 | } 250 | 251 | putSamples(samples: Float32Array, position = 0, numFrames = -1) { 252 | const sourceOffset = position * 2; 253 | if (!(numFrames >= 0)) { 254 | numFrames = (samples.length - sourceOffset) / 2; 255 | } 256 | const numSamples = numFrames * 2; 257 | 258 | this.ensureCapacity(numFrames + this._frameCount); 259 | 260 | const destOffset = this.endIndex; 261 | this._vector.set( 262 | samples.subarray(sourceOffset, sourceOffset + numSamples), 263 | destOffset 264 | ); 265 | 266 | this._frameCount += numFrames; 267 | } 268 | 269 | putBuffer(buffer: FifoSampleBuffer, position = 0, numFrames = -1) { 270 | if (!(numFrames >= 0)) { 271 | numFrames = buffer.frameCount - position; 272 | } 273 | this.putSamples(buffer.vector, buffer.position + position, numFrames); 274 | } 275 | 276 | receive(numFrames: number = -1) { 277 | if (!(numFrames >= 0) || numFrames > this._frameCount) { 278 | numFrames = this._frameCount; 279 | } 280 | this._frameCount -= numFrames; 281 | this._position += numFrames; 282 | } 283 | 284 | receiveSamples(output: Float32Array, numFrames: number) { 285 | const numSamples = numFrames * 2; 286 | const sourceOffset = this.startIndex; 287 | output.set(this._vector.subarray(sourceOffset, sourceOffset + numSamples)); 288 | this.receive(numFrames); 289 | } 290 | 291 | extract(output: Float32Array, position: number, numFrames: number) { 292 | const sourceOffset = this.startIndex + position * 2; 293 | const numSamples = numFrames * 2; 294 | output.set(this._vector.subarray(sourceOffset, sourceOffset + numSamples)); 295 | } 296 | 297 | ensureCapacity(numFrames: number) { 298 | const minLength = numFrames * 2; 299 | if (this._vector.length < minLength) { 300 | const newVector = new Float32Array(minLength); 301 | newVector.set(this._vector.subarray(this.startIndex, this.endIndex)); 302 | this._vector = newVector; 303 | this._position = 0; 304 | } else { 305 | this.rewind(); 306 | } 307 | } 308 | 309 | ensureAdditionalCapacity(numFrames: number) { 310 | this.ensureCapacity(this.frameCount + numFrames); 311 | } 312 | 313 | rewind() { 314 | if (this._position > 0) { 315 | this._vector.set(this._vector.subarray(this.startIndex, this.endIndex)); 316 | this._position = 0; 317 | } 318 | } 319 | } 320 | 321 | class Stretch extends AbstractFifoSamplePipe { 322 | sampleRate: number = 44100; 323 | 324 | sequenceMs: number = DEFAULT_SEQUENCE_MS; 325 | seekWindowMs: number = DEFAULT_SEEKWINDOW_MS; 326 | overlapMs: number = DEFAULT_OVERLAP_MS; 327 | 328 | bQuickSeek: boolean = true; 329 | bMidBufferDirty: boolean = false; 330 | 331 | pRefMidBuffer = new Float32Array(0); 332 | pMidBuffer: Float32Array | null = new Float32Array(0); 333 | overlapLength: number = 0; 334 | 335 | bAutoSeqSetting: boolean = true; 336 | bAutoSeekSetting: boolean = true; 337 | 338 | nominalSkip: number = 0; 339 | skipFract: number = 0; 340 | 341 | seekWindowLength: number = 0; 342 | seekLength: number = 0; 343 | sampleReq: number = 0; 344 | 345 | private _tempo: number = 1; 346 | 347 | constructor(createBuffers: boolean, sampleRate: number) { 348 | super(createBuffers); 349 | 350 | this.setParameters( 351 | sampleRate, 352 | DEFAULT_SEQUENCE_MS, 353 | DEFAULT_SEEKWINDOW_MS, 354 | DEFAULT_OVERLAP_MS 355 | ); 356 | } 357 | 358 | clear() { 359 | AbstractFifoSamplePipe.prototype.clear.call(this); 360 | this._clearMidBuffer(); 361 | } 362 | 363 | _clearMidBuffer() { 364 | if (this.bMidBufferDirty) { 365 | this.bMidBufferDirty = false; 366 | this.pMidBuffer = null; 367 | } 368 | } 369 | 370 | /** 371 | * Sets routine control parameters. These control are certain time constants 372 | * defining how the sound is stretched to the desired duration. 373 | * 374 | * 'sampleRate' = sample rate of the sound 375 | * 'sequenceMS' = one processing sequence length in milliseconds (default = 82 ms) 376 | * 'seekwindowMS' = seeking window length for scanning the best overlapping 377 | * position (default = 28 ms) 378 | * 'overlapMS' = overlapping length (default = 12 ms) 379 | */ 380 | setParameters( 381 | aSampleRate: number, 382 | aSequenceMS: number, 383 | aSeekWindowMS: number, 384 | aOverlapMS: number 385 | ) { 386 | // accept only positive parameter values - if zero or negative, use old values instead 387 | if (aSampleRate > 0) { 388 | this.sampleRate = aSampleRate; 389 | } 390 | if (aOverlapMS > 0) { 391 | this.overlapMs = aOverlapMS; 392 | } 393 | 394 | if (aSequenceMS > 0) { 395 | this.sequenceMs = aSequenceMS; 396 | this.bAutoSeqSetting = false; 397 | } else { 398 | // zero or below, use automatic setting 399 | this.bAutoSeqSetting = true; 400 | } 401 | 402 | if (aSeekWindowMS > 0) { 403 | this.seekWindowMs = aSeekWindowMS; 404 | this.bAutoSeekSetting = false; 405 | } else { 406 | // zero or below, use automatic setting 407 | this.bAutoSeekSetting = true; 408 | } 409 | 410 | this.calcSeqParameters(); 411 | 412 | this.calculateOverlapLength(this.overlapMs); 413 | 414 | // set tempo to recalculate 'sampleReq' 415 | this.tempo = this._tempo; 416 | } 417 | 418 | /** 419 | * Sets new target tempo. Normal tempo = 'SCALE', smaller values represent slower 420 | * tempo, larger faster tempo. 421 | */ 422 | set tempo(newTempo) { 423 | let intskip; 424 | 425 | this._tempo = newTempo; 426 | 427 | // Calculate new sequence duration 428 | this.calcSeqParameters(); 429 | 430 | // Calculate ideal skip length (according to tempo value) 431 | this.nominalSkip = 432 | this._tempo * (this.seekWindowLength - this.overlapLength); 433 | this.skipFract = 0; 434 | intskip = Math.floor(this.nominalSkip + 0.5); 435 | 436 | // Calculate how many samples are needed in the 'inputBuffer' to 437 | // process another batch of samples 438 | this.sampleReq = 439 | Math.max(intskip + this.overlapLength, this.seekWindowLength) + 440 | this.seekLength; 441 | } 442 | 443 | get tempo() { 444 | return this._tempo; 445 | } 446 | 447 | get inputChunkSize() { 448 | return this.sampleReq; 449 | } 450 | 451 | get outputChunkSize() { 452 | return ( 453 | this.overlapLength + 454 | Math.max(0, this.seekWindowLength - 2 * this.overlapLength) 455 | ); 456 | } 457 | 458 | /** 459 | * Calculates overlapInMsec period length in samples. 460 | */ 461 | calculateOverlapLength(overlapInMsec: number) { 462 | let newOvl; 463 | 464 | // TODO assert(overlapInMsec >= 0); 465 | newOvl = (this.sampleRate * overlapInMsec) / 1000; 466 | if (newOvl < 16) newOvl = 16; 467 | 468 | // must be divisible by 8 469 | newOvl -= newOvl % 8; 470 | 471 | this.overlapLength = newOvl; 472 | 473 | this.pRefMidBuffer = new Float32Array(this.overlapLength * 2); 474 | this.pMidBuffer = new Float32Array(this.overlapLength * 2); 475 | } 476 | 477 | checkLimits(x: number, mi: number, ma: number) { 478 | return x < mi ? mi : x > ma ? ma : x; 479 | } 480 | 481 | /** 482 | * Calculates processing sequence length according to tempo setting 483 | */ 484 | calcSeqParameters() { 485 | let seq; 486 | let seek; 487 | 488 | if (this.bAutoSeqSetting) { 489 | seq = AUTOSEQ_C + AUTOSEQ_K * this._tempo; 490 | seq = this.checkLimits(seq, AUTOSEQ_AT_MAX, AUTOSEQ_AT_MIN); 491 | this.sequenceMs = Math.floor(seq + 0.5); 492 | } 493 | 494 | if (this.bAutoSeekSetting) { 495 | seek = AUTOSEEK_C + AUTOSEEK_K * this._tempo; 496 | seek = this.checkLimits(seek, AUTOSEEK_AT_MAX, AUTOSEEK_AT_MIN); 497 | this.seekWindowMs = Math.floor(seek + 0.5); 498 | } 499 | 500 | // Update seek window lengths 501 | this.seekWindowLength = Math.floor( 502 | (this.sampleRate * this.sequenceMs) / 1000 503 | ); 504 | this.seekLength = Math.floor((this.sampleRate * this.seekWindowMs) / 1000); 505 | } 506 | 507 | /** 508 | * Enables/disables the quick position seeking algorithm. 509 | */ 510 | set quickSeek(enable: boolean) { 511 | this.bQuickSeek = enable; 512 | } 513 | 514 | /** 515 | * Seeks for the optimal overlap-mixing position. 516 | */ 517 | seekBestOverlapPosition() { 518 | if (this.bQuickSeek) { 519 | return this.seekBestOverlapPositionStereoQuick(); 520 | } else { 521 | return this.seekBestOverlapPositionStereo(); 522 | } 523 | } 524 | 525 | /** 526 | * Seeks for the optimal overlap-mixing position. The 'stereo' version of the 527 | * routine 528 | * 529 | * The best position is determined as the position where the two overlapped 530 | * sample sequences are 'most alike', in terms of the highest cross-correlation 531 | * value over the overlapping period 532 | */ 533 | seekBestOverlapPositionStereo() { 534 | let bestOffs; 535 | let bestCorr; 536 | let corr; 537 | let i; 538 | 539 | // Slopes the amplitudes of the 'midBuffer' samples. 540 | this.precalcCorrReferenceStereo(); 541 | 542 | bestCorr = Number.MIN_VALUE; 543 | bestOffs = 0; 544 | 545 | // Scans for the best correlation value by testing each possible position 546 | // over the permitted range. 547 | for (i = 0; i < this.seekLength; i++) { 548 | // Calculates correlation value for the mixing position corresponding 549 | // to 'i' 550 | corr = this.calcCrossCorrStereo(2 * i, this.pRefMidBuffer); 551 | 552 | // Checks for the highest correlation value. 553 | if (corr > bestCorr) { 554 | bestCorr = corr; 555 | bestOffs = i; 556 | } 557 | } 558 | return bestOffs; 559 | } 560 | 561 | /** 562 | * Seeks for the optimal overlap-mixing position. The 'stereo' version of the 563 | * routine 564 | * 565 | * The best position is determined as the position where the two overlapped 566 | * sample sequences are 'most alike', in terms of the highest cross-correlation 567 | * value over the overlapping period 568 | */ 569 | seekBestOverlapPositionStereoQuick() { 570 | let j; 571 | let bestOffs; 572 | let bestCorr; 573 | let corr; 574 | let scanCount; 575 | let corrOffset; 576 | let tempOffset; 577 | 578 | // Slopes the amplitude of the 'midBuffer' samples 579 | this.precalcCorrReferenceStereo(); 580 | 581 | bestCorr = Number.MIN_VALUE; 582 | bestOffs = 0; 583 | corrOffset = 0; 584 | tempOffset = 0; 585 | 586 | // Scans for the best correlation value using four-pass hierarchical search. 587 | // 588 | // The look-up table 'scans' has hierarchical position adjusting steps. 589 | // In first pass the routine searhes for the highest correlation with 590 | // relatively coarse steps, then rescans the neighbourhood of the highest 591 | // correlation with better resolution and so on. 592 | for (scanCount = 0; scanCount < 4; scanCount++) { 593 | j = 0; 594 | while (_SCAN_OFFSETS[scanCount][j]) { 595 | tempOffset = corrOffset + _SCAN_OFFSETS[scanCount][j]; 596 | if (tempOffset >= this.seekLength) { 597 | break; 598 | } 599 | 600 | // Calculates correlation value for the mixing position corresponding 601 | // to 'tempOffset' 602 | corr = this.calcCrossCorrStereo(2 * tempOffset, this.pRefMidBuffer); 603 | 604 | // Checks for the highest correlation value 605 | if (corr > bestCorr) { 606 | bestCorr = corr; 607 | bestOffs = tempOffset; 608 | } 609 | j++; 610 | } 611 | corrOffset = bestOffs; 612 | } 613 | return bestOffs; 614 | } 615 | 616 | /** 617 | * Slopes the amplitude of the 'midBuffer' samples so that cross correlation 618 | * is faster to calculate 619 | */ 620 | precalcCorrReferenceStereo() { 621 | let i; 622 | let cnt2; 623 | let temp; 624 | 625 | for (i = 0; i < this.overlapLength; i++) { 626 | temp = i * (this.overlapLength - i); 627 | cnt2 = i * 2; 628 | this.pRefMidBuffer[cnt2] = this.pMidBuffer![cnt2] * temp; 629 | this.pRefMidBuffer[cnt2 + 1] = this.pMidBuffer![cnt2 + 1] * temp; 630 | } 631 | } 632 | 633 | calcCrossCorrStereo(mixingPos: number, compare: Float32Array) { 634 | const mixing = this.inputBuffer!.vector; 635 | mixingPos += this.inputBuffer!.startIndex; 636 | 637 | let corr; 638 | let i; 639 | let mixingOffset; 640 | corr = 0; 641 | for (i = 2; i < 2 * this.overlapLength; i += 2) { 642 | mixingOffset = i + mixingPos; 643 | corr += 644 | mixing[mixingOffset] * compare[i] + 645 | mixing[mixingOffset + 1] * compare[i + 1]; 646 | } 647 | return corr; 648 | } 649 | 650 | // TODO inline 651 | /** 652 | * Overlaps samples in 'midBuffer' with the samples in 'pInputBuffer' at position 653 | * of 'ovlPos'. 654 | */ 655 | overlap(ovlPos: number) { 656 | this.overlapStereo(2 * ovlPos); 657 | } 658 | 659 | /** 660 | * Overlaps samples in 'midBuffer' with the samples in 'pInput' 661 | */ 662 | overlapStereo(pInputPos: number) { 663 | const pInput = this.inputBuffer!.vector; 664 | pInputPos += this.inputBuffer!.startIndex; 665 | 666 | const pOutput = this.outputBuffer!.vector; 667 | const pOutputPos = this.outputBuffer!.endIndex; 668 | let i; 669 | let cnt2; 670 | let fTemp; 671 | let fScale; 672 | let fi; 673 | let pInputOffset; 674 | let pOutputOffset; 675 | 676 | fScale = 1 / this.overlapLength; 677 | for (i = 0; i < this.overlapLength; i++) { 678 | fTemp = (this.overlapLength - i) * fScale; 679 | fi = i * fScale; 680 | cnt2 = 2 * i; 681 | pInputOffset = cnt2 + pInputPos; 682 | pOutputOffset = cnt2 + pOutputPos; 683 | pOutput[pOutputOffset + 0] = 684 | pInput[pInputOffset + 0] * fi + this.pMidBuffer![cnt2 + 0] * fTemp; 685 | pOutput[pOutputOffset + 1] = 686 | pInput[pInputOffset + 1] * fi + this.pMidBuffer![cnt2 + 1] * fTemp; 687 | } 688 | } 689 | 690 | process() { 691 | let ovlSkip; 692 | let offset; 693 | let temp; 694 | let i; 695 | if (this.pMidBuffer === null) { 696 | // if midBuffer is empty, move the first samples of the input stream 697 | // into it 698 | if (this.inputBuffer!.frameCount < this.overlapLength) { 699 | // wait until we've got overlapLength samples 700 | return; 701 | } 702 | this.pMidBuffer = new Float32Array(this.overlapLength * 2); 703 | this.inputBuffer!.receiveSamples(this.pMidBuffer, this.overlapLength); 704 | } 705 | 706 | let output; 707 | // Process samples as long as there are enough samples in 'inputBuffer' 708 | // to form a processing frame. 709 | while (this.inputBuffer!.frameCount >= this.sampleReq) { 710 | // If tempo differs from the normal ('SCALE'), scan for the best overlapping 711 | // position 712 | offset = this.seekBestOverlapPosition(); 713 | 714 | // Mix the samples in the 'inputBuffer' at position of 'offset' with the 715 | // samples in 'midBuffer' using sliding overlapping 716 | // ... first partially overlap with the end of the previous sequence 717 | // (that's in 'midBuffer') 718 | this.outputBuffer!.ensureAdditionalCapacity(this.overlapLength); 719 | // FIXME unit? 720 | //overlap(uint(offset)); 721 | this.overlap(Math.floor(offset)); 722 | this.outputBuffer!.put(this.overlapLength); 723 | 724 | // ... then copy sequence samples from 'inputBuffer' to output 725 | temp = this.seekWindowLength - 2 * this.overlapLength; // & 0xfffffffe; 726 | if (temp > 0) { 727 | this.outputBuffer!.putBuffer( 728 | this.inputBuffer!, 729 | offset + this.overlapLength, 730 | temp 731 | ); 732 | } 733 | 734 | // Copies the end of the current sequence from 'inputBuffer' to 735 | // 'midBuffer' for being mixed with the beginning of the next 736 | // processing sequence and so on 737 | //assert(offset + seekWindowLength <= (int)inputBuffer.numSamples()); 738 | const start = 739 | this.inputBuffer!.startIndex + 740 | 2 * (offset + this.seekWindowLength - this.overlapLength); 741 | this.pMidBuffer.set( 742 | this.inputBuffer!.vector.subarray(start, start + 2 * this.overlapLength) 743 | ); 744 | 745 | // Remove the processed samples from the input buffer. Update 746 | // the difference between integer & nominal skip step to 'skipFract' 747 | // in order to prevent the error from accumulating over time. 748 | this.skipFract += this.nominalSkip; // real skip size 749 | ovlSkip = Math.floor(this.skipFract); // rounded to integer skip 750 | this.skipFract -= ovlSkip; // maintain the fraction part, i.e. real vs. integer skip 751 | this.inputBuffer!.receive(ovlSkip); 752 | } 753 | } 754 | } 755 | 756 | class SoundTouch { 757 | rateTransposer: RateTransposer; 758 | tdStretch: Stretch; 759 | 760 | _inputBuffer: FifoSampleBuffer; 761 | _intermediateBuffer: FifoSampleBuffer; 762 | _outputBuffer: FifoSampleBuffer; 763 | 764 | _rate = 0; 765 | _tempo = 0; 766 | 767 | virtualPitch = 1.0; 768 | virtualRate = 1.0; 769 | virtualTempo = 1.0; 770 | 771 | constructor(sampleRate: number) { 772 | this.rateTransposer = new RateTransposer(false); 773 | this.tdStretch = new Stretch(false, sampleRate); 774 | 775 | this._inputBuffer = new FifoSampleBuffer(); 776 | this._intermediateBuffer = new FifoSampleBuffer(); 777 | this._outputBuffer = new FifoSampleBuffer(); 778 | 779 | this._rate = 0; 780 | this._tempo = 0; 781 | 782 | this.virtualPitch = 1.0; 783 | this.virtualRate = 1.0; 784 | this.virtualTempo = 1.0; 785 | 786 | this._calculateEffectiveRateAndTempo(); 787 | } 788 | 789 | clear() { 790 | this.rateTransposer.clear(); 791 | this.tdStretch.clear(); 792 | } 793 | 794 | get rate() { 795 | return this._rate; 796 | } 797 | 798 | set rate(rate) { 799 | this.virtualRate = rate; 800 | this._calculateEffectiveRateAndTempo(); 801 | } 802 | 803 | rateChange(rateChange: number) { 804 | this.rate = 1.0 + 0.01 * rateChange; 805 | } 806 | 807 | get tempo() { 808 | return this._tempo; 809 | } 810 | 811 | set tempo(tempo) { 812 | this.virtualTempo = tempo; 813 | this._calculateEffectiveRateAndTempo(); 814 | } 815 | 816 | set tempoChange(tempoChange: number) { 817 | this.tempo = 1.0 + 0.01 * tempoChange; 818 | } 819 | 820 | set pitch(pitch: number) { 821 | this.virtualPitch = pitch; 822 | this._calculateEffectiveRateAndTempo(); 823 | } 824 | 825 | set pitchOctaves(pitchOctaves: number) { 826 | this.pitch = Math.exp(0.69314718056 * pitchOctaves); 827 | this._calculateEffectiveRateAndTempo(); 828 | } 829 | 830 | set pitchSemitones(pitchSemitones: number) { 831 | this.pitchOctaves = pitchSemitones / 12.0; 832 | } 833 | 834 | get inputBuffer() { 835 | return this._inputBuffer!; 836 | } 837 | 838 | get outputBuffer() { 839 | return this._outputBuffer!; 840 | } 841 | 842 | _calculateEffectiveRateAndTempo() { 843 | const previousTempo = this._tempo; 844 | const previousRate = this._rate; 845 | 846 | this._tempo = this.virtualTempo / this.virtualPitch; 847 | this._rate = this.virtualRate * this.virtualPitch; 848 | 849 | if (testFloatEqual(this._tempo, previousTempo)) { 850 | this.tdStretch.tempo = this._tempo; 851 | } 852 | if (testFloatEqual(this._rate, previousRate)) { 853 | this.rateTransposer.rate = this._rate; 854 | } 855 | 856 | if (this._rate > 1.0) { 857 | if (this.outputBuffer! != this.rateTransposer.outputBuffer) { 858 | this.tdStretch.inputBuffer = this.inputBuffer!; 859 | this.tdStretch.outputBuffer = this._intermediateBuffer; 860 | 861 | this.rateTransposer.inputBuffer = this._intermediateBuffer; 862 | this.rateTransposer.outputBuffer = this.outputBuffer!; 863 | } 864 | } else { 865 | if (this.outputBuffer! != this.tdStretch.outputBuffer) { 866 | this.rateTransposer.inputBuffer = this.inputBuffer!; 867 | this.rateTransposer.outputBuffer = this._intermediateBuffer; 868 | 869 | this.tdStretch.inputBuffer = this._intermediateBuffer; 870 | this.tdStretch.outputBuffer = this.outputBuffer!; 871 | } 872 | } 873 | } 874 | 875 | process() { 876 | if (this._rate > 1.0) { 877 | this.tdStretch.process(); 878 | this.rateTransposer.process(); 879 | } else { 880 | this.rateTransposer.process(); 881 | this.tdStretch.process(); 882 | } 883 | } 884 | } 885 | 886 | export { RateTransposer, Stretch, SoundTouch }; 887 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "esnext", 5 | "target": "ES2018", 6 | "esModuleInterop": true, 7 | "moduleResolution": "node" 8 | }, 9 | "include": ["src/**/*"], 10 | "exclude": ["node_modules", "./package.json"] 11 | } 12 | --------------------------------------------------------------------------------