├── .gitignore ├── .prettierrc ├── dist ├── lib │ ├── worklets │ │ ├── audio_processor.d.ts │ │ ├── audio_processor.d.ts.map │ │ ├── stream_processor.d.ts.map │ │ └── stream_processor.d.ts │ ├── analysis │ │ ├── constants.d.ts.map │ │ ├── constants.d.ts │ │ ├── audio_analysis.d.ts.map │ │ └── audio_analysis.d.ts │ ├── wav_packer.d.ts.map │ ├── wav_stream_player.d.ts.map │ ├── wav_recorder.d.ts.map │ ├── wav_packer.d.ts │ ├── wav_stream_player.d.ts │ └── wav_recorder.d.ts ├── index.d.ts.map └── index.d.ts ├── .eslintrc.json ├── tsconfig.json ├── index.js ├── package.json ├── LICENSE ├── lib ├── analysis │ ├── constants.js │ └── audio_analysis.js ├── worklets │ ├── stream_processor.js │ └── audio_processor.js ├── wav_packer.js ├── wav_stream_player.js └── wav_recorder.js ├── README.md └── script ├── wavtools.min.js └── wavtools.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | .env -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /dist/lib/worklets/audio_processor.d.ts: -------------------------------------------------------------------------------- 1 | export const AudioProcessorSrc: any; 2 | //# sourceMappingURL=audio_processor.d.ts.map -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "sourceType": "module" 4 | }, 5 | "env": { 6 | "es2022": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /dist/index.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.js"],"names":[],"mappings":"8BAC8B,kCAAkC;0BADtC,qBAAqB;gCAEf,4BAA4B;4BAChC,uBAAuB"} -------------------------------------------------------------------------------- /dist/lib/worklets/audio_processor.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"audio_processor.d.ts","sourceRoot":"","sources":["../../../lib/worklets/audio_processor.js"],"names":[],"mappings":"AAqNA,oCAAqC"} -------------------------------------------------------------------------------- /dist/lib/worklets/stream_processor.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"stream_processor.d.ts","sourceRoot":"","sources":["../../../lib/worklets/stream_processor.js"],"names":[],"mappings":"AAAA,q4FAyFE;AAMF,qCAAsC"} -------------------------------------------------------------------------------- /dist/lib/analysis/constants.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../lib/analysis/constants.js"],"names":[],"mappings":"AA6BA;;;GAGG;AACH,oCAAkC;AAClC,wCAAsC;AActC,qCAKG;AACH,yCAKG"} -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["index.js"], 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "outDir": "dist", 8 | "declarationMap": true, 9 | "lib": ["ES2022"] 10 | } 11 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { WavPacker } from './lib/wav_packer.js'; 2 | import { AudioAnalysis } from './lib/analysis/audio_analysis.js'; 3 | import { WavStreamPlayer } from './lib/wav_stream_player.js'; 4 | import { WavRecorder } from './lib/wav_recorder.js'; 5 | 6 | export { AudioAnalysis, WavPacker, WavStreamPlayer, WavRecorder }; 7 | -------------------------------------------------------------------------------- /dist/lib/analysis/constants.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * All note frequencies from 1st to 8th octave 3 | * in format "A#8" (A#, 8th octave) 4 | */ 5 | export const noteFrequencies: any[]; 6 | export const noteFrequencyLabels: any[]; 7 | export const voiceFrequencies: any[]; 8 | export const voiceFrequencyLabels: any[]; 9 | //# sourceMappingURL=constants.d.ts.map -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | import { AudioAnalysis } from './lib/analysis/audio_analysis.js'; 2 | import { WavPacker } from './lib/wav_packer.js'; 3 | import { WavStreamPlayer } from './lib/wav_stream_player.js'; 4 | import { WavRecorder } from './lib/wav_recorder.js'; 5 | export { AudioAnalysis, WavPacker, WavStreamPlayer, WavRecorder }; 6 | //# sourceMappingURL=index.d.ts.map -------------------------------------------------------------------------------- /dist/lib/wav_packer.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"wav_packer.d.ts","sourceRoot":"","sources":["../../lib/wav_packer.js"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;GAGG;AACH;IACE;;;;OAIG;IACH,qCAHW,YAAY,GACV,WAAW,CAWvB;IAED;;;;;OAKG;IACH,gCAJW,WAAW,eACX,WAAW,GACT,WAAW,CASvB;IAED;;;;;;OAMG;IACH,kBAKC;IAED;;;;;OAKG;IACH,iBAJW,MAAM,SACN;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,KAAK,CAAC,YAAY,CAAC,CAAC;QAAC,IAAI,EAAE,UAAU,CAAA;KAAC,GACtE,kBAAkB,CA6C9B;CACF;;;;;UA3Ga,IAAI;SACJ,MAAM;kBACN,MAAM;gBACN,MAAM;cACN,MAAM"} -------------------------------------------------------------------------------- /dist/lib/analysis/audio_analysis.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"audio_analysis.d.ts","sourceRoot":"","sources":["../../../lib/analysis/audio_analysis.js"],"names":[],"mappings":"AAOA;;;;;;GAMG;AAEH;;;GAGG;AACH;IACE;;;;;;;;;;OAUG;IACH,gCARW,YAAY,cACZ,MAAM,cACN,YAAY,iBACZ,WAAW,GAAC,OAAO,GAAC,OAAO,gBAC3B,MAAM,gBACN,MAAM,GACJ,uBAAuB,CAwDnC;IAED;;;;;OAKG;IACH,0BAJW,gBAAgB,gBAChB,WAAW,GAAC,IAAI,EAkE1B;IA9DC,kBAAoB;IA2ClB,wBAAyB;IACzB,aAAkC;IAClC,cAAwB;IACxB,gBAA4B;IAC5B,iBAA8B;IAiBlC;;;;;;OAMG;IACH,8BALW,WAAW,GAAC,OAAO,GAAC,OAAO,gBAC3B,MAAM,gBACN,MAAM,GACJ,uBAAuB,CAwBnC;IAED;;;;OAIG;IACH,qBAFa,OAAO,CAAC,IAAI,CAAC,CAOzB;CACF;;;;;;;;YA9La,YAAY;;;;iBACZ,MAAM,EAAE;;;;YACR,MAAM,EAAE"} -------------------------------------------------------------------------------- /dist/lib/wav_stream_player.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"wav_stream_player.d.ts","sourceRoot":"","sources":["../../lib/wav_stream_player.js"],"names":[],"mappings":"AAGA;;;GAGG;AACH;IACE;;;;OAIG;IACH,6BAHW;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAC,EAW/B;IAPC,eAAmC;IACnC,mBAA4B;IAC5B,aAAmB;IACnB,YAAkB;IAClB,cAAoB;IACpB,uBAA4B;IAC5B,wBAA6B;IAG/B;;;OAGG;IACH,WAFa,OAAO,CAAC,IAAI,CAAC,CAkBzB;IAED;;;;;;OAMG;IACH,8BALW,WAAW,GAAC,OAAO,GAAC,OAAO,gBAC3B,MAAM,gBACN,MAAM,GACJ,OAAO,8BAA8B,EAAE,uBAAuB,CAkB1E;IAED;;;;OAIG;IACH,eAkBC;IAED;;;;;;OAMG;IACH,yBAJW,WAAW,GAAC,UAAU,YACtB,MAAM,GACJ,UAAU,CAqBtB;IAED;;;;OAIG;IACH,iCAHW,OAAO,GACL;QAAC,OAAO,EAAE,MAAM,GAAC,IAAI,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAC,CAqBvE;IAED;;;;OAIG;IACH,aAFa;QAAC,OAAO,EAAE,MAAM,GAAC,IAAI,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAC,CAIvE;CACF"} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wavtools", 3 | "version": "0.1.5", 4 | "description": "Record and stream WAV audio data in the browser across all platforms", 5 | "main": "index.js", 6 | "types": "./dist/index.d.ts", 7 | "type": "module", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/keithwhor/wavtools.git" 11 | }, 12 | "scripts": { 13 | "test": "echo \"Error: no test specified\" && exit 1", 14 | "compile": "npx tsc && npx esbuild index.js --bundle --outfile=script/wavtools.js --format=iife && npx esbuild index.js --bundle --minify --outfile=script/wavtools.min.js --format=iife" 15 | }, 16 | "keywords": [ 17 | "Wavtools", 18 | "WAV", 19 | "Audio", 20 | "Browser" 21 | ], 22 | "author": "Keith Horwoood ", 23 | "license": "MIT", 24 | "devDependencies": { 25 | "esbuild": "^0.24.0", 26 | "typescript": "^5.6.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Keith Horwood 4 | Copyright (c) 2024 OpenAI 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /dist/lib/wav_recorder.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"wav_recorder.d.ts","sourceRoot":"","sources":["../../lib/wav_recorder.js"],"names":[],"mappings":"AAIA;;;;;;;GAOG;AAEH;;;GAGG;AACH;IAsCE;;;;;;OAMG;IACH,yBALW,IAAI,GAAC,YAAY,GAAC,UAAU,GAAC,WAAW,GAAC,MAAM,EAAE,eACjD,MAAM,mBACN,MAAM,GACJ,OAAO,CAAC,gBAAgB,CAAC,CAqErC;IA/GD;;;;OAIG;IACH,uDAHW;QAAC,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,gBAAgB,CAAC,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAC,EAiC5E;IAxBC,eAAkC;IAElC,mBAA4B;IAC5B,0BAAwC;IACxC,eAAoB;IACpB,2CAAiC;IACjC,gBAAkB;IAElB,YAAkB;IAClB,eAAqB;IACrB,YAAkB;IAClB,UAAgB;IAChB,mBAAsB;IAEtB,qBAAqB;IACrB,kBAAuB;IACvB,qBAAwB;IAExB,4BAA+B;IAE/B;;;MAGC;IA+EH;;;;OAIG;IACH,qBAFa,IAAI,CAOhB;IAED;;;OAGG;IACH,iBAFa,MAAM,CAIlB;IAED;;;OAGG;IACH,aAFa,OAAO,GAAC,QAAQ,GAAC,WAAW,CAUxC;IAED;;;;;;;OAOG;IACH,eAqBC;IAED;;;;OAIG;IACH,sCAFa,IAAI,CAmChB;IAED;;;OAGG;IACH,qBAFa,OAAO,CAAC,IAAI,CAAC,CAoBzB;IAED;;;OAGG;IACH,eAFa,OAAO,CAAC,KAAK,CAAC,eAAe,GAAG;QAAC,OAAO,EAAE,OAAO,CAAA;KAAC,CAAC,CAAC,CA8BhE;IAED;;;;;OAKG;IACH,iBAHW,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CAkFzB;IAHC,cAAwB;IAK1B;;;;;;OAMG;IACH,8BALW,WAAW,GAAC,OAAO,GAAC,OAAO,gBAC3B,MAAM,gBACN,MAAM,GACJ,OAAO,8BAA8B,EAAE,uBAAuB,CAkB1E;IAED;;;;OAIG;IACH,SAFa,OAAO,CAAC,IAAI,CAAC,CAezB;IAED;;;;;OAKG;IACH,wBAJW,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,UAAU,CAAC;QAAC,GAAG,EAAE,UAAU,CAAA;KAAE,KAAK,GAAG,cACpD,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CAoBzB;IATC,4BAAoC;IAWtC;;;OAGG;IACH,SAFa,OAAO,CAAC,IAAI,CAAC,CAQzB;IAED;;;OAGG;IACH,QAFa,OAAO,CAAC;QAAC,UAAU,EAAE,YAAY,CAAC;QAAC,QAAQ,EAAE,KAAK,CAAC,YAAY,CAAC,CAAA;KAAC,CAAC,CAS9E;IAED;;;;OAIG;IACH,aAHW,OAAO,GACL,OAAO,CAAC,OAAO,iBAAiB,EAAE,kBAAkB,CAAC,CAgBjE;IAED;;;OAGG;IACH,OAFa,OAAO,CAAC,OAAO,iBAAiB,EAAE,kBAAkB,CAAC,CA8BjE;IAED;;;;OAIG;IACH,QAFa,OAAO,CAAC,IAAI,CAAC,CAQzB;CACF;;;;;UA1hBa,IAAI;SACJ,MAAM;YACN,YAAY;iBACZ,WAAW"} -------------------------------------------------------------------------------- /lib/analysis/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Constants for help with visualization 3 | * Helps map frequency ranges from Fast Fourier Transform 4 | * to human-interpretable ranges, notably music ranges and 5 | * human vocal ranges. 6 | */ 7 | 8 | // Eighth octave frequencies 9 | const octave8Frequencies = [ 10 | 4186.01, 4434.92, 4698.63, 4978.03, 5274.04, 5587.65, 5919.91, 6271.93, 11 | 6644.88, 7040.0, 7458.62, 7902.13, 12 | ]; 13 | 14 | // Labels for each of the above frequencies 15 | const octave8FrequencyLabels = [ 16 | 'C', 17 | 'C#', 18 | 'D', 19 | 'D#', 20 | 'E', 21 | 'F', 22 | 'F#', 23 | 'G', 24 | 'G#', 25 | 'A', 26 | 'A#', 27 | 'B', 28 | ]; 29 | 30 | /** 31 | * All note frequencies from 1st to 8th octave 32 | * in format "A#8" (A#, 8th octave) 33 | */ 34 | export const noteFrequencies = []; 35 | export const noteFrequencyLabels = []; 36 | for (let i = 1; i <= 8; i++) { 37 | for (let f = 0; f < octave8Frequencies.length; f++) { 38 | const freq = octave8Frequencies[f]; 39 | noteFrequencies.push(freq / Math.pow(2, 8 - i)); 40 | noteFrequencyLabels.push(octave8FrequencyLabels[f] + i); 41 | } 42 | } 43 | 44 | /** 45 | * Subset of the note frequencies between 32 and 2000 Hz 46 | * 6 octave range: C1 to B6 47 | */ 48 | const voiceFrequencyRange = [32.0, 2000.0]; 49 | export const voiceFrequencies = noteFrequencies.filter((_, i) => { 50 | return ( 51 | noteFrequencies[i] > voiceFrequencyRange[0] && 52 | noteFrequencies[i] < voiceFrequencyRange[1] 53 | ); 54 | }); 55 | export const voiceFrequencyLabels = noteFrequencyLabels.filter((_, i) => { 56 | return ( 57 | noteFrequencies[i] > voiceFrequencyRange[0] && 58 | noteFrequencies[i] < voiceFrequencyRange[1] 59 | ); 60 | }); 61 | -------------------------------------------------------------------------------- /dist/lib/wav_packer.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Raw wav audio file contents 3 | * @typedef {Object} WavPackerAudioType 4 | * @property {Blob} blob 5 | * @property {string} url 6 | * @property {number} channelCount 7 | * @property {number} sampleRate 8 | * @property {number} duration 9 | */ 10 | /** 11 | * Utility class for assembling PCM16 "audio/wav" data 12 | * @class 13 | */ 14 | export class WavPacker { 15 | /** 16 | * Converts Float32Array of amplitude data to ArrayBuffer in Int16Array format 17 | * @param {Float32Array} float32Array 18 | * @returns {ArrayBuffer} 19 | */ 20 | static floatTo16BitPCM(float32Array: Float32Array): ArrayBuffer; 21 | /** 22 | * Concatenates two ArrayBuffers 23 | * @param {ArrayBuffer} leftBuffer 24 | * @param {ArrayBuffer} rightBuffer 25 | * @returns {ArrayBuffer} 26 | */ 27 | static mergeBuffers(leftBuffer: ArrayBuffer, rightBuffer: ArrayBuffer): ArrayBuffer; 28 | /** 29 | * Packs data into an Int16 format 30 | * @private 31 | * @param {number} size 0 = 1x Int16, 1 = 2x Int16 32 | * @param {number} arg value to pack 33 | * @returns 34 | */ 35 | private _packData; 36 | /** 37 | * Packs audio into "audio/wav" Blob 38 | * @param {number} sampleRate 39 | * @param {{bitsPerSample: number, channels: Array, data: Int16Array}} audio 40 | * @returns {WavPackerAudioType} 41 | */ 42 | pack(sampleRate: number, audio: { 43 | bitsPerSample: number; 44 | channels: Array; 45 | data: Int16Array; 46 | }): WavPackerAudioType; 47 | } 48 | /** 49 | * Raw wav audio file contents 50 | */ 51 | export type WavPackerAudioType = { 52 | blob: Blob; 53 | url: string; 54 | channelCount: number; 55 | sampleRate: number; 56 | duration: number; 57 | }; 58 | //# sourceMappingURL=wav_packer.d.ts.map -------------------------------------------------------------------------------- /dist/lib/wav_stream_player.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Plays audio streams received in raw PCM16 chunks from the browser 3 | * @class 4 | */ 5 | export class WavStreamPlayer { 6 | /** 7 | * Creates a new WavStreamPlayer instance 8 | * @param {{sampleRate?: number}} options 9 | * @returns {WavStreamPlayer} 10 | */ 11 | constructor({ sampleRate }?: { 12 | sampleRate?: number; 13 | }); 14 | scriptSrc: any; 15 | sampleRate: number; 16 | context: any; 17 | stream: any; 18 | analyser: any; 19 | trackSampleOffsets: {}; 20 | interruptedTrackIds: {}; 21 | /** 22 | * Connects the audio context and enables output to speakers 23 | * @returns {Promise} 24 | */ 25 | connect(): Promise; 26 | /** 27 | * Gets the current frequency domain data from the playing track 28 | * @param {"frequency"|"music"|"voice"} [analysisType] 29 | * @param {number} [minDecibels] default -100 30 | * @param {number} [maxDecibels] default -30 31 | * @returns {import('./analysis/audio_analysis.js').AudioAnalysisOutputType} 32 | */ 33 | getFrequencies(analysisType?: "frequency" | "music" | "voice", minDecibels?: number, maxDecibels?: number): import("./analysis/audio_analysis.js").AudioAnalysisOutputType; 34 | /** 35 | * Starts audio streaming 36 | * @private 37 | * @returns {Promise} 38 | */ 39 | private _start; 40 | /** 41 | * Adds 16BitPCM data to the currently playing audio stream 42 | * You can add chunks beyond the current play point and they will be queued for play 43 | * @param {ArrayBuffer|Int16Array} arrayBuffer 44 | * @param {string} [trackId] 45 | * @returns {Int16Array} 46 | */ 47 | add16BitPCM(arrayBuffer: ArrayBuffer | Int16Array, trackId?: string): Int16Array; 48 | /** 49 | * Gets the offset (sample count) of the currently playing stream 50 | * @param {boolean} [interrupt] 51 | * @returns {{trackId: string|null, offset: number, currentTime: number}} 52 | */ 53 | getTrackSampleOffset(interrupt?: boolean): { 54 | trackId: string | null; 55 | offset: number; 56 | currentTime: number; 57 | }; 58 | /** 59 | * Strips the current stream and returns the sample offset of the audio 60 | * @param {boolean} [interrupt] 61 | * @returns {{trackId: string|null, offset: number, currentTime: number}} 62 | */ 63 | interrupt(): { 64 | trackId: string | null; 65 | offset: number; 66 | currentTime: number; 67 | }; 68 | } 69 | //# sourceMappingURL=wav_stream_player.d.ts.map -------------------------------------------------------------------------------- /dist/lib/analysis/audio_analysis.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Output of AudioAnalysis for the frequency domain of the audio 3 | * @typedef {Object} AudioAnalysisOutputType 4 | * @property {Float32Array} values Amplitude of this frequency between {0, 1} inclusive 5 | * @property {number[]} frequencies Raw frequency bucket values 6 | * @property {string[]} labels Labels for the frequency bucket values 7 | */ 8 | /** 9 | * Analyzes audio for visual output 10 | * @class 11 | */ 12 | export class AudioAnalysis { 13 | /** 14 | * Retrieves frequency domain data from an AnalyserNode adjusted to a decibel range 15 | * returns human-readable formatting and labels 16 | * @param {AnalyserNode} analyser 17 | * @param {number} sampleRate 18 | * @param {Float32Array} [fftResult] 19 | * @param {"frequency"|"music"|"voice"} [analysisType] 20 | * @param {number} [minDecibels] default -100 21 | * @param {number} [maxDecibels] default -30 22 | * @returns {AudioAnalysisOutputType} 23 | */ 24 | static getFrequencies(analyser: AnalyserNode, sampleRate: number, fftResult?: Float32Array, analysisType?: "frequency" | "music" | "voice", minDecibels?: number, maxDecibels?: number): AudioAnalysisOutputType; 25 | /** 26 | * Creates a new AudioAnalysis instance for an HTMLAudioElement 27 | * @param {HTMLAudioElement} audioElement 28 | * @param {AudioBuffer|null} [audioBuffer] If provided, will cache all frequency domain data from the buffer 29 | * @returns {AudioAnalysis} 30 | */ 31 | constructor(audioElement: HTMLAudioElement, audioBuffer?: AudioBuffer | null); 32 | fftResults: any[]; 33 | audio: HTMLAudioElement; 34 | context: any; 35 | analyser: any; 36 | sampleRate: any; 37 | audioBuffer: any; 38 | /** 39 | * Gets the current frequency domain data from the playing audio track 40 | * @param {"frequency"|"music"|"voice"} [analysisType] 41 | * @param {number} [minDecibels] default -100 42 | * @param {number} [maxDecibels] default -30 43 | * @returns {AudioAnalysisOutputType} 44 | */ 45 | getFrequencies(analysisType?: "frequency" | "music" | "voice", minDecibels?: number, maxDecibels?: number): AudioAnalysisOutputType; 46 | /** 47 | * Resume the internal AudioContext if it was suspended due to the lack of 48 | * user interaction when the AudioAnalysis was instantiated. 49 | * @returns {Promise} 50 | */ 51 | resumeIfSuspended(): Promise; 52 | } 53 | /** 54 | * Output of AudioAnalysis for the frequency domain of the audio 55 | */ 56 | export type AudioAnalysisOutputType = { 57 | /** 58 | * Amplitude of this frequency between {0, 1} inclusive 59 | */ 60 | values: Float32Array; 61 | /** 62 | * Raw frequency bucket values 63 | */ 64 | frequencies: number[]; 65 | /** 66 | * Labels for the frequency bucket values 67 | */ 68 | labels: string[]; 69 | }; 70 | //# sourceMappingURL=audio_analysis.d.ts.map -------------------------------------------------------------------------------- /dist/lib/worklets/stream_processor.d.ts: -------------------------------------------------------------------------------- 1 | export const StreamProcessorWorklet: "\nclass StreamProcessor extends AudioWorkletProcessor {\n constructor() {\n super();\n this.hasStarted = false;\n this.hasInterrupted = false;\n this.outputBuffers = [];\n this.bufferLength = 128;\n this.write = { buffer: new Float32Array(this.bufferLength), trackId: null };\n this.writeOffset = 0;\n this.trackSampleOffsets = {};\n this.port.onmessage = (event) => {\n if (event.data) {\n const payload = event.data;\n if (payload.event === 'write') {\n const int16Array = payload.buffer;\n const float32Array = new Float32Array(int16Array.length);\n for (let i = 0; i < int16Array.length; i++) {\n float32Array[i] = int16Array[i] / 0x8000; // Convert Int16 to Float32\n }\n this.writeData(float32Array, payload.trackId);\n } else if (\n payload.event === 'offset' ||\n payload.event === 'interrupt'\n ) {\n const requestId = payload.requestId;\n const trackId = this.write.trackId;\n const offset = this.trackSampleOffsets[trackId] || 0;\n this.port.postMessage({\n event: 'offset',\n requestId,\n trackId,\n offset,\n });\n if (payload.event === 'interrupt') {\n this.hasInterrupted = true;\n }\n } else {\n throw new Error(`Unhandled event \"${payload.event}\"`);\n }\n }\n };\n }\n\n writeData(float32Array, trackId = null) {\n let { buffer } = this.write;\n let offset = this.writeOffset;\n for (let i = 0; i < float32Array.length; i++) {\n buffer[offset++] = float32Array[i];\n if (offset >= buffer.length) {\n this.outputBuffers.push(this.write);\n this.write = { buffer: new Float32Array(this.bufferLength), trackId };\n buffer = this.write.buffer;\n offset = 0;\n }\n }\n this.writeOffset = offset;\n return true;\n }\n\n process(inputs, outputs, parameters) {\n const output = outputs[0];\n const outputChannelData = output[0];\n const outputBuffers = this.outputBuffers;\n if (this.hasInterrupted) {\n this.port.postMessage({ event: 'stop' });\n return false;\n } else if (outputBuffers.length) {\n this.hasStarted = true;\n const { buffer, trackId } = outputBuffers.shift();\n for (let i = 0; i < outputChannelData.length; i++) {\n outputChannelData[i] = buffer[i] || 0;\n }\n if (trackId) {\n this.trackSampleOffsets[trackId] =\n this.trackSampleOffsets[trackId] || 0;\n this.trackSampleOffsets[trackId] += buffer.length;\n }\n return true;\n } else if (this.hasStarted) {\n this.port.postMessage({ event: 'stop' });\n return false;\n } else {\n return true;\n }\n }\n}\n\nregisterProcessor('stream_processor', StreamProcessor);\n"; 2 | export const StreamProcessorSrc: any; 3 | //# sourceMappingURL=stream_processor.d.ts.map -------------------------------------------------------------------------------- /lib/worklets/stream_processor.js: -------------------------------------------------------------------------------- 1 | export const StreamProcessorWorklet = ` 2 | class StreamProcessor extends AudioWorkletProcessor { 3 | constructor() { 4 | super(); 5 | this.hasStarted = false; 6 | this.hasInterrupted = false; 7 | this.outputBuffers = []; 8 | this.bufferLength = 128; 9 | this.write = { buffer: new Float32Array(this.bufferLength), trackId: null }; 10 | this.writeOffset = 0; 11 | this.trackSampleOffsets = {}; 12 | this.port.onmessage = (event) => { 13 | if (event.data) { 14 | const payload = event.data; 15 | if (payload.event === 'write') { 16 | const int16Array = payload.buffer; 17 | const float32Array = new Float32Array(int16Array.length); 18 | for (let i = 0; i < int16Array.length; i++) { 19 | float32Array[i] = int16Array[i] / 0x8000; // Convert Int16 to Float32 20 | } 21 | this.writeData(float32Array, payload.trackId); 22 | } else if ( 23 | payload.event === 'offset' || 24 | payload.event === 'interrupt' 25 | ) { 26 | const requestId = payload.requestId; 27 | const trackId = this.write.trackId; 28 | const offset = this.trackSampleOffsets[trackId] || 0; 29 | this.port.postMessage({ 30 | event: 'offset', 31 | requestId, 32 | trackId, 33 | offset, 34 | }); 35 | if (payload.event === 'interrupt') { 36 | this.hasInterrupted = true; 37 | } 38 | } else { 39 | throw new Error(\`Unhandled event "\${payload.event}"\`); 40 | } 41 | } 42 | }; 43 | } 44 | 45 | writeData(float32Array, trackId = null) { 46 | let { buffer } = this.write; 47 | let offset = this.writeOffset; 48 | for (let i = 0; i < float32Array.length; i++) { 49 | buffer[offset++] = float32Array[i]; 50 | if (offset >= buffer.length) { 51 | this.outputBuffers.push(this.write); 52 | this.write = { buffer: new Float32Array(this.bufferLength), trackId }; 53 | buffer = this.write.buffer; 54 | offset = 0; 55 | } 56 | } 57 | this.writeOffset = offset; 58 | return true; 59 | } 60 | 61 | process(inputs, outputs, parameters) { 62 | const output = outputs[0]; 63 | const outputChannelData = output[0]; 64 | const outputBuffers = this.outputBuffers; 65 | if (this.hasInterrupted) { 66 | this.port.postMessage({ event: 'stop' }); 67 | return false; 68 | } else if (outputBuffers.length) { 69 | this.hasStarted = true; 70 | const { buffer, trackId } = outputBuffers.shift(); 71 | for (let i = 0; i < outputChannelData.length; i++) { 72 | outputChannelData[i] = buffer[i] || 0; 73 | } 74 | if (trackId) { 75 | this.trackSampleOffsets[trackId] = 76 | this.trackSampleOffsets[trackId] || 0; 77 | this.trackSampleOffsets[trackId] += buffer.length; 78 | } 79 | return true; 80 | } else if (this.hasStarted) { 81 | this.port.postMessage({ event: 'stop' }); 82 | return false; 83 | } else { 84 | return true; 85 | } 86 | } 87 | } 88 | 89 | registerProcessor('stream_processor', StreamProcessor); 90 | `; 91 | 92 | const script = new Blob([StreamProcessorWorklet], { 93 | type: 'application/javascript', 94 | }); 95 | const src = URL.createObjectURL(script); 96 | export const StreamProcessorSrc = src; 97 | -------------------------------------------------------------------------------- /lib/wav_packer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Raw wav audio file contents 3 | * @typedef {Object} WavPackerAudioType 4 | * @property {Blob} blob 5 | * @property {string} url 6 | * @property {number} channelCount 7 | * @property {number} sampleRate 8 | * @property {number} duration 9 | */ 10 | 11 | /** 12 | * Utility class for assembling PCM16 "audio/wav" data 13 | * @class 14 | */ 15 | export class WavPacker { 16 | /** 17 | * Converts Float32Array of amplitude data to ArrayBuffer in Int16Array format 18 | * @param {Float32Array} float32Array 19 | * @returns {ArrayBuffer} 20 | */ 21 | static floatTo16BitPCM(float32Array) { 22 | const buffer = new ArrayBuffer(float32Array.length * 2); 23 | const view = new DataView(buffer); 24 | let offset = 0; 25 | for (let i = 0; i < float32Array.length; i++, offset += 2) { 26 | let s = Math.max(-1, Math.min(1, float32Array[i])); 27 | view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); 28 | } 29 | return buffer; 30 | } 31 | 32 | /** 33 | * Concatenates two ArrayBuffers 34 | * @param {ArrayBuffer} leftBuffer 35 | * @param {ArrayBuffer} rightBuffer 36 | * @returns {ArrayBuffer} 37 | */ 38 | static mergeBuffers(leftBuffer, rightBuffer) { 39 | const tmpArray = new Uint8Array( 40 | leftBuffer.byteLength + rightBuffer.byteLength 41 | ); 42 | tmpArray.set(new Uint8Array(leftBuffer), 0); 43 | tmpArray.set(new Uint8Array(rightBuffer), leftBuffer.byteLength); 44 | return tmpArray.buffer; 45 | } 46 | 47 | /** 48 | * Packs data into an Int16 format 49 | * @private 50 | * @param {number} size 0 = 1x Int16, 1 = 2x Int16 51 | * @param {number} arg value to pack 52 | * @returns 53 | */ 54 | _packData(size, arg) { 55 | return [ 56 | new Uint8Array([arg, arg >> 8]), 57 | new Uint8Array([arg, arg >> 8, arg >> 16, arg >> 24]), 58 | ][size]; 59 | } 60 | 61 | /** 62 | * Packs audio into "audio/wav" Blob 63 | * @param {number} sampleRate 64 | * @param {{bitsPerSample: number, channels: Array, data: Int16Array}} audio 65 | * @returns {WavPackerAudioType} 66 | */ 67 | pack(sampleRate, audio) { 68 | if (!audio?.bitsPerSample) { 69 | throw new Error(`Missing "bitsPerSample"`); 70 | } else if (!audio?.channels) { 71 | throw new Error(`Missing "channels"`); 72 | } else if (!audio?.data) { 73 | throw new Error(`Missing "data"`); 74 | } 75 | const { bitsPerSample, channels, data } = audio; 76 | const output = [ 77 | // Header 78 | 'RIFF', 79 | this._packData( 80 | 1, 81 | 4 + (8 + 24) /* chunk 1 length */ + (8 + 8) /* chunk 2 length */ 82 | ), // Length 83 | 'WAVE', 84 | // chunk 1 85 | 'fmt ', // Sub-chunk identifier 86 | this._packData(1, 16), // Chunk length 87 | this._packData(0, 1), // Audio format (1 is linear quantization) 88 | this._packData(0, channels.length), 89 | this._packData(1, sampleRate), 90 | this._packData(1, (sampleRate * channels.length * bitsPerSample) / 8), // Byte rate 91 | this._packData(0, (channels.length * bitsPerSample) / 8), 92 | this._packData(0, bitsPerSample), 93 | // chunk 2 94 | 'data', // Sub-chunk identifier 95 | this._packData( 96 | 1, 97 | (channels[0].length * channels.length * bitsPerSample) / 8 98 | ), // Chunk length 99 | data, 100 | ]; 101 | const blob = new Blob(output, { type: 'audio/mpeg' }); 102 | const url = URL.createObjectURL(blob); 103 | return { 104 | blob, 105 | url, 106 | channelCount: channels.length, 107 | sampleRate, 108 | duration: data.byteLength / (channels.length * sampleRate * 2), 109 | }; 110 | } 111 | } 112 | 113 | globalThis.WavPacker = WavPacker; 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wavtools 2 | 3 | wavtools is a library for both recording and streaming Waveform Audio (.wav) data 4 | in the browser. It is intended for managing PCM16 audio streams directly. 5 | 6 | This is a fork of open source, MIT licensed tooling initially 7 | developed at OpenAI as part of the [OpenAI Realtime Console](https://github.com/openai/openai-realtime-console), developed by [Keith Horwood](https://x.com/keithwhor). 8 | 9 | The two most important classes are the `WavRecorder` used for capturing audio 10 | in the browser, and `WavStreamPlayer` for queueing and streaming audio chunks to the user. 11 | 12 | ## Installation and usage 13 | 14 | To install wavtools in a Webpack project; 15 | 16 | ```shell 17 | $ npm i wavtools --save 18 | ``` 19 | 20 | ```javascript 21 | import { WavRecorder, WavStreamPlayer } from 'wavtools'; 22 | 23 | const wavRecorder = new WavRecorder({ sampleRate: 24000 }); 24 | wavRecorder.getStatus(); // "ended" 25 | ``` 26 | 27 | To use as a standalone script, download the [script/wavtools.js](/script/wavtools.js) or 28 | [script/wavtools.min.js](/script/wavtools.js) files and import them; 29 | 30 | ```html 31 | 32 | 37 | ``` 38 | 39 | ## WavRecorder Quickstart 40 | 41 | ```javascript 42 | import { WavRecorder } from 'wavtools'; 43 | 44 | const wavRecorder = new WavRecorder({ sampleRate: 24000 }); 45 | wavRecorder.getStatus(); // "ended" 46 | 47 | // request permissions, connect microphone 48 | await wavRecorder.begin(); 49 | wavRecorder.getStatus(); // "paused" 50 | 51 | // Start recording 52 | // This callback will be triggered in chunks of 8192 samples by default 53 | // { mono, raw } are Int16Array (PCM16) mono & full channel data 54 | await wavRecorder.record((data) => { 55 | const { mono, raw } = data; 56 | }); 57 | wavRecorder.getStatus(); // "recording" 58 | 59 | // Stop recording 60 | await wavRecorder.pause(); 61 | wavRecorder.getStatus(); // "paused" 62 | 63 | // outputs "audio/wav" audio file 64 | const audio = await wavRecorder.save(); 65 | 66 | // clears current audio buffer and starts recording 67 | await wavRecorder.clear(); 68 | await wavRecorder.record(); 69 | 70 | // get data for visualization 71 | const frequencyData = wavRecorder.getFrequencies(); 72 | 73 | // Stop recording, disconnects microphone, output file 74 | await wavRecorder.pause(); 75 | const finalAudio = await wavRecorder.end(); 76 | 77 | // Listen for device change; e.g. if somebody disconnects a microphone 78 | // deviceList is array of MediaDeviceInfo[] + `default` property 79 | wavRecorder.listenForDeviceChange((deviceList) => {}); 80 | ``` 81 | 82 | ## WavStreamPlayer Quickstart 83 | 84 | ```javascript 85 | import { WavStreamPlayer } from '/src/lib/wavtools/index.js'; 86 | 87 | const wavStreamPlayer = new WavStreamPlayer({ sampleRate: 24000 }); 88 | 89 | // Connect to audio output 90 | await wavStreamPlayer.connect(); 91 | 92 | // Create 1s of empty PCM16 audio 93 | const audio = new Int16Array(24000); 94 | // Queue 3s of audio, will start playing immediately 95 | wavStreamPlayer.add16BitPCM(audio, 'my-track'); 96 | wavStreamPlayer.add16BitPCM(audio, 'my-track'); 97 | wavStreamPlayer.add16BitPCM(audio, 'my-track'); 98 | 99 | // get data for visualization 100 | const frequencyData = wavStreamPlayer.getFrequencies(); 101 | 102 | // Interrupt the audio (halt playback) at any time 103 | // To restart, need to call .add16BitPCM() again 104 | const trackOffset = await wavStreamPlayer.interrupt(); 105 | trackOffset.trackId; // "my-track" 106 | trackOffset.offset; // sample number 107 | trackOffset.currentTime; // time in track 108 | ``` 109 | 110 | # Compilation 111 | 112 | When modifying the repository, to create appropriate TypeScript types and 113 | JavaScript bundles, use `npm run compile`. 114 | 115 | # Acknowledgements and contact 116 | 117 | Thanks to the OpenAI Realtime team! Without their awesome work this library would not 118 | be needed. 119 | 120 | - OpenAI Developers / [@OpenAIDevs](https://x.com/OpenAIDevs) 121 | - Jordan Sitkin / API / [@dustmason](https://x.com/dustmason) 122 | - Mark Hudnall / API / [@landakram](https://x.com/landakram) 123 | - Peter Bakkum / API / [@pbbakkum](https://x.com/pbbakkum) 124 | - Atty Eleti / API / [@athyuttamre](https://x.com/athyuttamre) 125 | - Jason Clark / API / [@onebitToo](https://x.com/onebitToo) 126 | - Karolis Kosas / Design / [@karoliskosas](https://x.com/karoliskosas) 127 | - Romain Huet / DX / [@romainhuet](https://x.com/romainhuet) 128 | - Katia Gil Guzman / DX / [@kagigz](https://x.com/kagigz) 129 | - Ilan Bigio / DX / [@ilanbigio](https://x.com/ilanbigio) 130 | - Kevin Whinnery / DX / [@kevinwhinnery](https://x.com/kevinwhinnery) 131 | 132 | You can reach me directly at; 133 | 134 | - Keith Horwood / [@keithwhor](https://x.com/keithwhor) 135 | -------------------------------------------------------------------------------- /lib/wav_stream_player.js: -------------------------------------------------------------------------------- 1 | import { StreamProcessorSrc } from './worklets/stream_processor.js'; 2 | import { AudioAnalysis } from './analysis/audio_analysis.js'; 3 | 4 | /** 5 | * Plays audio streams received in raw PCM16 chunks from the browser 6 | * @class 7 | */ 8 | export class WavStreamPlayer { 9 | /** 10 | * Creates a new WavStreamPlayer instance 11 | * @param {{sampleRate?: number}} options 12 | * @returns {WavStreamPlayer} 13 | */ 14 | constructor({ sampleRate = 44100 } = {}) { 15 | this.scriptSrc = StreamProcessorSrc; 16 | this.sampleRate = sampleRate; 17 | this.context = null; 18 | this.stream = null; 19 | this.analyser = null; 20 | this.trackSampleOffsets = {}; 21 | this.interruptedTrackIds = {}; 22 | } 23 | 24 | /** 25 | * Connects the audio context and enables output to speakers 26 | * @returns {Promise} 27 | */ 28 | async connect() { 29 | this.context = new AudioContext({ sampleRate: this.sampleRate }); 30 | if (this.context.state === 'suspended') { 31 | await this.context.resume(); 32 | } 33 | try { 34 | await this.context.audioWorklet.addModule(this.scriptSrc); 35 | } catch (e) { 36 | console.error(e); 37 | throw new Error(`Could not add audioWorklet module: ${this.scriptSrc}`); 38 | } 39 | const analyser = this.context.createAnalyser(); 40 | analyser.fftSize = 8192; 41 | analyser.smoothingTimeConstant = 0.1; 42 | this.analyser = analyser; 43 | return true; 44 | } 45 | 46 | /** 47 | * Gets the current frequency domain data from the playing track 48 | * @param {"frequency"|"music"|"voice"} [analysisType] 49 | * @param {number} [minDecibels] default -100 50 | * @param {number} [maxDecibels] default -30 51 | * @returns {import('./analysis/audio_analysis.js').AudioAnalysisOutputType} 52 | */ 53 | getFrequencies( 54 | analysisType = 'frequency', 55 | minDecibels = -100, 56 | maxDecibels = -30 57 | ) { 58 | if (!this.analyser) { 59 | throw new Error('Not connected, please call .connect() first'); 60 | } 61 | return AudioAnalysis.getFrequencies( 62 | this.analyser, 63 | this.sampleRate, 64 | null, 65 | analysisType, 66 | minDecibels, 67 | maxDecibels 68 | ); 69 | } 70 | 71 | /** 72 | * Starts audio streaming 73 | * @private 74 | * @returns {Promise} 75 | */ 76 | _start() { 77 | const streamNode = new AudioWorkletNode(this.context, 'stream_processor'); 78 | streamNode.connect(this.context.destination); 79 | streamNode.port.onmessage = (e) => { 80 | const { event } = e.data; 81 | if (event === 'stop') { 82 | streamNode.disconnect(); 83 | this.stream = null; 84 | } else if (event === 'offset') { 85 | const { requestId, trackId, offset } = e.data; 86 | const currentTime = offset / this.sampleRate; 87 | this.trackSampleOffsets[requestId] = { trackId, offset, currentTime }; 88 | } 89 | }; 90 | this.analyser.disconnect(); 91 | streamNode.connect(this.analyser); 92 | this.stream = streamNode; 93 | return true; 94 | } 95 | 96 | /** 97 | * Adds 16BitPCM data to the currently playing audio stream 98 | * You can add chunks beyond the current play point and they will be queued for play 99 | * @param {ArrayBuffer|Int16Array} arrayBuffer 100 | * @param {string} [trackId] 101 | * @returns {Int16Array} 102 | */ 103 | add16BitPCM(arrayBuffer, trackId = 'default') { 104 | if (typeof trackId !== 'string') { 105 | throw new Error(`trackId must be a string`); 106 | } else if (this.interruptedTrackIds[trackId]) { 107 | return; 108 | } 109 | if (!this.stream) { 110 | this._start(); 111 | } 112 | let buffer; 113 | if (arrayBuffer instanceof Int16Array) { 114 | buffer = arrayBuffer; 115 | } else if (arrayBuffer instanceof ArrayBuffer) { 116 | buffer = new Int16Array(arrayBuffer); 117 | } else { 118 | throw new Error(`argument must be Int16Array or ArrayBuffer`); 119 | } 120 | this.stream.port.postMessage({ event: 'write', buffer, trackId }); 121 | return buffer; 122 | } 123 | 124 | /** 125 | * Gets the offset (sample count) of the currently playing stream 126 | * @param {boolean} [interrupt] 127 | * @returns {{trackId: string|null, offset: number, currentTime: number}} 128 | */ 129 | async getTrackSampleOffset(interrupt = false) { 130 | if (!this.stream) { 131 | return null; 132 | } 133 | const requestId = crypto.randomUUID(); 134 | this.stream.port.postMessage({ 135 | event: interrupt ? 'interrupt' : 'offset', 136 | requestId, 137 | }); 138 | let trackSampleOffset; 139 | while (!trackSampleOffset) { 140 | trackSampleOffset = this.trackSampleOffsets[requestId]; 141 | await new Promise((r) => setTimeout(() => r(), 1)); 142 | } 143 | const { trackId } = trackSampleOffset; 144 | if (interrupt && trackId) { 145 | this.interruptedTrackIds[trackId] = true; 146 | } 147 | return trackSampleOffset; 148 | } 149 | 150 | /** 151 | * Strips the current stream and returns the sample offset of the audio 152 | * @param {boolean} [interrupt] 153 | * @returns {{trackId: string|null, offset: number, currentTime: number}} 154 | */ 155 | async interrupt() { 156 | return this.getTrackSampleOffset(true); 157 | } 158 | } 159 | 160 | globalThis.WavStreamPlayer = WavStreamPlayer; 161 | -------------------------------------------------------------------------------- /dist/lib/wav_recorder.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Decodes audio into a wav file 3 | * @typedef {Object} DecodedAudioType 4 | * @property {Blob} blob 5 | * @property {string} url 6 | * @property {Float32Array} values 7 | * @property {AudioBuffer} audioBuffer 8 | */ 9 | /** 10 | * Records live stream of user audio as PCM16 "audio/wav" data 11 | * @class 12 | */ 13 | export class WavRecorder { 14 | /** 15 | * Decodes audio data from multiple formats to a Blob, url, Float32Array and AudioBuffer 16 | * @param {Blob|Float32Array|Int16Array|ArrayBuffer|number[]} audioData 17 | * @param {number} sampleRate 18 | * @param {number} fromSampleRate 19 | * @returns {Promise} 20 | */ 21 | static decode(audioData: Blob | Float32Array | Int16Array | ArrayBuffer | number[], sampleRate?: number, fromSampleRate?: number): Promise; 22 | /** 23 | * Create a new WavRecorder instance 24 | * @param {{sampleRate?: number, outputToSpeakers?: boolean, debug?: boolean}} [options] 25 | * @returns {WavRecorder} 26 | */ 27 | constructor({ sampleRate, outputToSpeakers, debug, }?: { 28 | sampleRate?: number; 29 | outputToSpeakers?: boolean; 30 | debug?: boolean; 31 | }); 32 | scriptSrc: any; 33 | sampleRate: number; 34 | outputToSpeakers: boolean; 35 | debug: boolean; 36 | _deviceChangeCallback: () => Promise; 37 | _devices: any[]; 38 | stream: any; 39 | processor: any; 40 | source: any; 41 | node: any; 42 | recording: boolean; 43 | _lastEventId: number; 44 | eventReceipts: {}; 45 | eventTimeout: number; 46 | _chunkProcessor: () => void; 47 | _chunkProcessorBuffer: { 48 | raw: ArrayBuffer; 49 | mono: ArrayBuffer; 50 | }; 51 | /** 52 | * Logs data in debug mode 53 | * @param {...any} arguments 54 | * @returns {true} 55 | */ 56 | log(...args: any[]): true; 57 | /** 58 | * Retrieves the current sampleRate for the recorder 59 | * @returns {number} 60 | */ 61 | getSampleRate(): number; 62 | /** 63 | * Retrieves the current status of the recording 64 | * @returns {"ended"|"paused"|"recording"} 65 | */ 66 | getStatus(): "ended" | "paused" | "recording"; 67 | /** 68 | * Sends an event to the AudioWorklet 69 | * @private 70 | * @param {string} name 71 | * @param {{[key: string]: any}} data 72 | * @param {AudioWorkletNode} [_processor] 73 | * @returns {Promise<{[key: string]: any}>} 74 | */ 75 | private _event; 76 | /** 77 | * Sets device change callback, remove if callback provided is `null` 78 | * @param {(Array): void|null} callback 79 | * @returns {true} 80 | */ 81 | listenForDeviceChange(callback: any): true; 82 | /** 83 | * Manually request permission to use the microphone 84 | * @returns {Promise} 85 | */ 86 | requestPermission(): Promise; 87 | /** 88 | * List all eligible devices for recording, will request permission to use microphone 89 | * @returns {Promise>} 90 | */ 91 | listDevices(): Promise>; 94 | /** 95 | * Begins a recording session and requests microphone permissions if not already granted 96 | * Microphone recording indicator will appear on browser tab but status will be "paused" 97 | * @param {string} [deviceId] if no device provided, default device will be used 98 | * @returns {Promise} 99 | */ 100 | begin(deviceId?: string): Promise; 101 | analyser: any; 102 | /** 103 | * Gets the current frequency domain data from the recording track 104 | * @param {"frequency"|"music"|"voice"} [analysisType] 105 | * @param {number} [minDecibels] default -100 106 | * @param {number} [maxDecibels] default -30 107 | * @returns {import('./analysis/audio_analysis.js').AudioAnalysisOutputType} 108 | */ 109 | getFrequencies(analysisType?: "frequency" | "music" | "voice", minDecibels?: number, maxDecibels?: number): import("./analysis/audio_analysis.js").AudioAnalysisOutputType; 110 | /** 111 | * Pauses the recording 112 | * Keeps microphone stream open but halts storage of audio 113 | * @returns {Promise} 114 | */ 115 | pause(): Promise; 116 | /** 117 | * Start recording stream and storing to memory from the connected audio source 118 | * @param {(data: { mono: Int16Array; raw: Int16Array }) => any} [chunkProcessor] 119 | * @param {number} [chunkSize] chunkProcessor will not be triggered until this size threshold met in mono audio 120 | * @returns {Promise} 121 | */ 122 | record(chunkProcessor?: (data: { 123 | mono: Int16Array; 124 | raw: Int16Array; 125 | }) => any, chunkSize?: number): Promise; 126 | _chunkProcessorSize: number; 127 | /** 128 | * Clears the audio buffer, empties stored recording 129 | * @returns {Promise} 130 | */ 131 | clear(): Promise; 132 | /** 133 | * Reads the current audio stream data 134 | * @returns {Promise<{meanValues: Float32Array, channels: Array}>} 135 | */ 136 | read(): Promise<{ 137 | meanValues: Float32Array; 138 | channels: Array; 139 | }>; 140 | /** 141 | * Saves the current audio stream to a file 142 | * @param {boolean} [force] Force saving while still recording 143 | * @returns {Promise} 144 | */ 145 | save(force?: boolean): Promise; 146 | /** 147 | * Ends the current recording session and saves the result 148 | * @returns {Promise} 149 | */ 150 | end(): Promise; 151 | /** 152 | * Performs a full cleanup of WavRecorder instance 153 | * Stops actively listening via microphone and removes existing listeners 154 | * @returns {Promise} 155 | */ 156 | quit(): Promise; 157 | } 158 | /** 159 | * Decodes audio into a wav file 160 | */ 161 | export type DecodedAudioType = { 162 | blob: Blob; 163 | url: string; 164 | values: Float32Array; 165 | audioBuffer: AudioBuffer; 166 | }; 167 | //# sourceMappingURL=wav_recorder.d.ts.map -------------------------------------------------------------------------------- /lib/worklets/audio_processor.js: -------------------------------------------------------------------------------- 1 | const AudioProcessorWorklet = ` 2 | class AudioProcessor extends AudioWorkletProcessor { 3 | 4 | constructor() { 5 | super(); 6 | this.port.onmessage = this.receive.bind(this); 7 | this.initialize(); 8 | } 9 | 10 | initialize() { 11 | this.foundAudio = false; 12 | this.recording = false; 13 | this.chunks = []; 14 | } 15 | 16 | /** 17 | * Concatenates sampled chunks into channels 18 | * Format is chunk[Left[], Right[]] 19 | */ 20 | readChannelData(chunks, channel = -1, maxChannels = 9) { 21 | let channelLimit; 22 | if (channel !== -1) { 23 | if (chunks[0] && chunks[0].length - 1 < channel) { 24 | throw new Error( 25 | \`Channel \${channel} out of range: max \${chunks[0].length}\` 26 | ); 27 | } 28 | channelLimit = channel + 1; 29 | } else { 30 | channel = 0; 31 | channelLimit = Math.min(chunks[0] ? chunks[0].length : 1, maxChannels); 32 | } 33 | const channels = []; 34 | for (let n = channel; n < channelLimit; n++) { 35 | const length = chunks.reduce((sum, chunk) => { 36 | return sum + chunk[n].length; 37 | }, 0); 38 | const buffers = chunks.map((chunk) => chunk[n]); 39 | const result = new Float32Array(length); 40 | let offset = 0; 41 | for (let i = 0; i < buffers.length; i++) { 42 | result.set(buffers[i], offset); 43 | offset += buffers[i].length; 44 | } 45 | channels[n] = result; 46 | } 47 | return channels; 48 | } 49 | 50 | /** 51 | * Combines parallel audio data into correct format, 52 | * channels[Left[], Right[]] to float32Array[LRLRLRLR...] 53 | */ 54 | formatAudioData(channels) { 55 | if (channels.length === 1) { 56 | // Simple case is only one channel 57 | const float32Array = channels[0].slice(); 58 | const meanValues = channels[0].slice(); 59 | return { float32Array, meanValues }; 60 | } else { 61 | const float32Array = new Float32Array( 62 | channels[0].length * channels.length 63 | ); 64 | const meanValues = new Float32Array(channels[0].length); 65 | for (let i = 0; i < channels[0].length; i++) { 66 | const offset = i * channels.length; 67 | let meanValue = 0; 68 | for (let n = 0; n < channels.length; n++) { 69 | float32Array[offset + n] = channels[n][i]; 70 | meanValue += channels[n][i]; 71 | } 72 | meanValues[i] = meanValue / channels.length; 73 | } 74 | return { float32Array, meanValues }; 75 | } 76 | } 77 | 78 | /** 79 | * Converts 32-bit float data to 16-bit integers 80 | */ 81 | floatTo16BitPCM(float32Array) { 82 | const buffer = new ArrayBuffer(float32Array.length * 2); 83 | const view = new DataView(buffer); 84 | let offset = 0; 85 | for (let i = 0; i < float32Array.length; i++, offset += 2) { 86 | let s = Math.max(-1, Math.min(1, float32Array[i])); 87 | view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); 88 | } 89 | return buffer; 90 | } 91 | 92 | /** 93 | * Retrieves the most recent amplitude values from the audio stream 94 | * @param {number} channel 95 | */ 96 | getValues(channel = -1) { 97 | const channels = this.readChannelData(this.chunks, channel); 98 | const { meanValues } = this.formatAudioData(channels); 99 | return { meanValues, channels }; 100 | } 101 | 102 | /** 103 | * Exports chunks as an audio/wav file 104 | */ 105 | export() { 106 | const channels = this.readChannelData(this.chunks); 107 | const { float32Array, meanValues } = this.formatAudioData(channels); 108 | const audioData = this.floatTo16BitPCM(float32Array); 109 | return { 110 | meanValues: meanValues, 111 | audio: { 112 | bitsPerSample: 16, 113 | channels: channels, 114 | data: audioData, 115 | }, 116 | }; 117 | } 118 | 119 | receive(e) { 120 | const { event, id } = e.data; 121 | let receiptData = {}; 122 | switch (event) { 123 | case 'start': 124 | this.recording = true; 125 | break; 126 | case 'stop': 127 | this.recording = false; 128 | break; 129 | case 'clear': 130 | this.initialize(); 131 | break; 132 | case 'export': 133 | receiptData = this.export(); 134 | break; 135 | case 'read': 136 | receiptData = this.getValues(); 137 | break; 138 | default: 139 | break; 140 | } 141 | // Always send back receipt 142 | this.port.postMessage({ event: 'receipt', id, data: receiptData }); 143 | } 144 | 145 | sendChunk(chunk) { 146 | const channels = this.readChannelData([chunk]); 147 | const { float32Array, meanValues } = this.formatAudioData(channels); 148 | const rawAudioData = this.floatTo16BitPCM(float32Array); 149 | const monoAudioData = this.floatTo16BitPCM(meanValues); 150 | this.port.postMessage({ 151 | event: 'chunk', 152 | data: { 153 | mono: monoAudioData, 154 | raw: rawAudioData, 155 | }, 156 | }); 157 | } 158 | 159 | process(inputList, outputList, parameters) { 160 | // Copy input to output (e.g. speakers) 161 | // Note that this creates choppy sounds with Mac products 162 | const sourceLimit = Math.min(inputList.length, outputList.length); 163 | for (let inputNum = 0; inputNum < sourceLimit; inputNum++) { 164 | const input = inputList[inputNum]; 165 | const output = outputList[inputNum]; 166 | const channelCount = Math.min(input.length, output.length); 167 | for (let channelNum = 0; channelNum < channelCount; channelNum++) { 168 | input[channelNum].forEach((sample, i) => { 169 | output[channelNum][i] = sample; 170 | }); 171 | } 172 | } 173 | const inputs = inputList[0]; 174 | // There's latency at the beginning of a stream before recording starts 175 | // Make sure we actually receive audio data before we start storing chunks 176 | let sliceIndex = 0; 177 | if (!this.foundAudio) { 178 | for (const channel of inputs) { 179 | sliceIndex = 0; // reset for each channel 180 | if (this.foundAudio) { 181 | break; 182 | } 183 | if (channel) { 184 | for (const value of channel) { 185 | if (value !== 0) { 186 | // find only one non-zero entry in any channel 187 | this.foundAudio = true; 188 | break; 189 | } else { 190 | sliceIndex++; 191 | } 192 | } 193 | } 194 | } 195 | } 196 | if (inputs && inputs[0] && this.foundAudio && this.recording) { 197 | // We need to copy the TypedArray, because the \`process\` 198 | // internals will reuse the same buffer to hold each input 199 | const chunk = inputs.map((input) => input.slice(sliceIndex)); 200 | this.chunks.push(chunk); 201 | this.sendChunk(chunk); 202 | } 203 | return true; 204 | } 205 | } 206 | 207 | registerProcessor('audio_processor', AudioProcessor); 208 | `; 209 | 210 | const script = new Blob([AudioProcessorWorklet], { 211 | type: 'application/javascript', 212 | }); 213 | const src = URL.createObjectURL(script); 214 | export const AudioProcessorSrc = src; 215 | -------------------------------------------------------------------------------- /lib/analysis/audio_analysis.js: -------------------------------------------------------------------------------- 1 | import { 2 | noteFrequencies, 3 | noteFrequencyLabels, 4 | voiceFrequencies, 5 | voiceFrequencyLabels, 6 | } from './constants.js'; 7 | 8 | /** 9 | * Output of AudioAnalysis for the frequency domain of the audio 10 | * @typedef {Object} AudioAnalysisOutputType 11 | * @property {Float32Array} values Amplitude of this frequency between {0, 1} inclusive 12 | * @property {number[]} frequencies Raw frequency bucket values 13 | * @property {string[]} labels Labels for the frequency bucket values 14 | */ 15 | 16 | /** 17 | * Analyzes audio for visual output 18 | * @class 19 | */ 20 | export class AudioAnalysis { 21 | /** 22 | * Retrieves frequency domain data from an AnalyserNode adjusted to a decibel range 23 | * returns human-readable formatting and labels 24 | * @param {AnalyserNode} analyser 25 | * @param {number} sampleRate 26 | * @param {Float32Array} [fftResult] 27 | * @param {"frequency"|"music"|"voice"} [analysisType] 28 | * @param {number} [minDecibels] default -100 29 | * @param {number} [maxDecibels] default -30 30 | * @returns {AudioAnalysisOutputType} 31 | */ 32 | static getFrequencies( 33 | analyser, 34 | sampleRate, 35 | fftResult, 36 | analysisType = 'frequency', 37 | minDecibels = -100, 38 | maxDecibels = -30, 39 | ) { 40 | if (!fftResult) { 41 | fftResult = new Float32Array(analyser.frequencyBinCount); 42 | analyser.getFloatFrequencyData(fftResult); 43 | } 44 | const nyquistFrequency = sampleRate / 2; 45 | const frequencyStep = (1 / fftResult.length) * nyquistFrequency; 46 | let outputValues; 47 | let frequencies; 48 | let labels; 49 | if (analysisType === 'music' || analysisType === 'voice') { 50 | const useFrequencies = 51 | analysisType === 'voice' ? voiceFrequencies : noteFrequencies; 52 | const aggregateOutput = Array(useFrequencies.length).fill(minDecibels); 53 | for (let i = 0; i < fftResult.length; i++) { 54 | const frequency = i * frequencyStep; 55 | const amplitude = fftResult[i]; 56 | for (let n = useFrequencies.length - 1; n >= 0; n--) { 57 | if (frequency > useFrequencies[n]) { 58 | aggregateOutput[n] = Math.max(aggregateOutput[n], amplitude); 59 | break; 60 | } 61 | } 62 | } 63 | outputValues = aggregateOutput; 64 | frequencies = 65 | analysisType === 'voice' ? voiceFrequencies : noteFrequencies; 66 | labels = 67 | analysisType === 'voice' ? voiceFrequencyLabels : noteFrequencyLabels; 68 | } else { 69 | outputValues = Array.from(fftResult); 70 | frequencies = outputValues.map((_, i) => frequencyStep * i); 71 | labels = frequencies.map((f) => `${f.toFixed(2)} Hz`); 72 | } 73 | // We normalize to {0, 1} 74 | const normalizedOutput = outputValues.map((v) => { 75 | return Math.max( 76 | 0, 77 | Math.min((v - minDecibels) / (maxDecibels - minDecibels), 1), 78 | ); 79 | }); 80 | const values = new Float32Array(normalizedOutput); 81 | return { 82 | values, 83 | frequencies, 84 | labels, 85 | }; 86 | } 87 | 88 | /** 89 | * Creates a new AudioAnalysis instance for an HTMLAudioElement 90 | * @param {HTMLAudioElement} audioElement 91 | * @param {AudioBuffer|null} [audioBuffer] If provided, will cache all frequency domain data from the buffer 92 | * @returns {AudioAnalysis} 93 | */ 94 | constructor(audioElement, audioBuffer = null) { 95 | this.fftResults = []; 96 | if (audioBuffer) { 97 | /** 98 | * Modified from 99 | * https://stackoverflow.com/questions/75063715/using-the-web-audio-api-to-analyze-a-song-without-playing 100 | * 101 | * We do this to populate FFT values for the audio if provided an `audioBuffer` 102 | * The reason to do this is that Safari fails when using `createMediaElementSource` 103 | * This has a non-zero RAM cost so we only opt-in to run it on Safari, Chrome is better 104 | */ 105 | const { length, sampleRate } = audioBuffer; 106 | const offlineAudioContext = new OfflineAudioContext({ 107 | length, 108 | sampleRate, 109 | }); 110 | const source = offlineAudioContext.createBufferSource(); 111 | source.buffer = audioBuffer; 112 | const analyser = offlineAudioContext.createAnalyser(); 113 | analyser.fftSize = 8192; 114 | analyser.smoothingTimeConstant = 0.1; 115 | source.connect(analyser); 116 | // limit is :: 128 / sampleRate; 117 | // but we just want 60fps - cuts ~1s from 6MB to 1MB of RAM 118 | const renderQuantumInSeconds = 1 / 60; 119 | const durationInSeconds = length / sampleRate; 120 | const analyze = (index) => { 121 | const suspendTime = renderQuantumInSeconds * index; 122 | if (suspendTime < durationInSeconds) { 123 | offlineAudioContext.suspend(suspendTime).then(() => { 124 | const fftResult = new Float32Array(analyser.frequencyBinCount); 125 | analyser.getFloatFrequencyData(fftResult); 126 | this.fftResults.push(fftResult); 127 | analyze(index + 1); 128 | }); 129 | } 130 | if (index === 1) { 131 | offlineAudioContext.startRendering(); 132 | } else { 133 | offlineAudioContext.resume(); 134 | } 135 | }; 136 | source.start(0); 137 | analyze(1); 138 | this.audio = audioElement; 139 | this.context = offlineAudioContext; 140 | this.analyser = analyser; 141 | this.sampleRate = sampleRate; 142 | this.audioBuffer = audioBuffer; 143 | } else { 144 | const audioContext = new AudioContext(); 145 | const track = audioContext.createMediaElementSource(audioElement); 146 | const analyser = audioContext.createAnalyser(); 147 | analyser.fftSize = 8192; 148 | analyser.smoothingTimeConstant = 0.1; 149 | track.connect(analyser); 150 | analyser.connect(audioContext.destination); 151 | this.audio = audioElement; 152 | this.context = audioContext; 153 | this.analyser = analyser; 154 | this.sampleRate = this.context.sampleRate; 155 | this.audioBuffer = null; 156 | } 157 | } 158 | 159 | /** 160 | * Gets the current frequency domain data from the playing audio track 161 | * @param {"frequency"|"music"|"voice"} [analysisType] 162 | * @param {number} [minDecibels] default -100 163 | * @param {number} [maxDecibels] default -30 164 | * @returns {AudioAnalysisOutputType} 165 | */ 166 | getFrequencies( 167 | analysisType = 'frequency', 168 | minDecibels = -100, 169 | maxDecibels = -30, 170 | ) { 171 | let fftResult = null; 172 | if (this.audioBuffer && this.fftResults.length) { 173 | const pct = this.audio.currentTime / this.audio.duration; 174 | const index = Math.min( 175 | (pct * this.fftResults.length) | 0, 176 | this.fftResults.length - 1, 177 | ); 178 | fftResult = this.fftResults[index]; 179 | } 180 | return AudioAnalysis.getFrequencies( 181 | this.analyser, 182 | this.sampleRate, 183 | fftResult, 184 | analysisType, 185 | minDecibels, 186 | maxDecibels, 187 | ); 188 | } 189 | 190 | /** 191 | * Resume the internal AudioContext if it was suspended due to the lack of 192 | * user interaction when the AudioAnalysis was instantiated. 193 | * @returns {Promise} 194 | */ 195 | async resumeIfSuspended() { 196 | if (this.context.state === 'suspended') { 197 | await this.context.resume(); 198 | } 199 | return true; 200 | } 201 | } 202 | 203 | globalThis.AudioAnalysis = AudioAnalysis; 204 | -------------------------------------------------------------------------------- /lib/wav_recorder.js: -------------------------------------------------------------------------------- 1 | import { AudioProcessorSrc } from './worklets/audio_processor.js'; 2 | import { AudioAnalysis } from './analysis/audio_analysis.js'; 3 | import { WavPacker } from './wav_packer.js'; 4 | 5 | /** 6 | * Decodes audio into a wav file 7 | * @typedef {Object} DecodedAudioType 8 | * @property {Blob} blob 9 | * @property {string} url 10 | * @property {Float32Array} values 11 | * @property {AudioBuffer} audioBuffer 12 | */ 13 | 14 | /** 15 | * Records live stream of user audio as PCM16 "audio/wav" data 16 | * @class 17 | */ 18 | export class WavRecorder { 19 | /** 20 | * Create a new WavRecorder instance 21 | * @param {{sampleRate?: number, outputToSpeakers?: boolean, debug?: boolean}} [options] 22 | * @returns {WavRecorder} 23 | */ 24 | constructor({ 25 | sampleRate = 44100, 26 | outputToSpeakers = false, 27 | debug = false, 28 | } = {}) { 29 | // Script source 30 | this.scriptSrc = AudioProcessorSrc; 31 | // Config 32 | this.sampleRate = sampleRate; 33 | this.outputToSpeakers = outputToSpeakers; 34 | this.debug = !!debug; 35 | this._deviceChangeCallback = null; 36 | this._devices = []; 37 | // State variables 38 | this.stream = null; 39 | this.processor = null; 40 | this.source = null; 41 | this.node = null; 42 | this.recording = false; 43 | // Event handling with AudioWorklet 44 | this._lastEventId = 0; 45 | this.eventReceipts = {}; 46 | this.eventTimeout = 5000; 47 | // Process chunks of audio 48 | this._chunkProcessor = () => {}; 49 | this._chunkProcessorSize = void 0; 50 | this._chunkProcessorBuffer = { 51 | raw: new ArrayBuffer(0), 52 | mono: new ArrayBuffer(0), 53 | }; 54 | } 55 | 56 | /** 57 | * Decodes audio data from multiple formats to a Blob, url, Float32Array and AudioBuffer 58 | * @param {Blob|Float32Array|Int16Array|ArrayBuffer|number[]} audioData 59 | * @param {number} sampleRate 60 | * @param {number} fromSampleRate 61 | * @returns {Promise} 62 | */ 63 | static async decode(audioData, sampleRate = 44100, fromSampleRate = -1) { 64 | const context = new AudioContext({ sampleRate }); 65 | let arrayBuffer; 66 | let blob; 67 | if (audioData instanceof Blob) { 68 | if (fromSampleRate !== -1) { 69 | throw new Error( 70 | `Can not specify "fromSampleRate" when reading from Blob`, 71 | ); 72 | } 73 | blob = audioData; 74 | arrayBuffer = await blob.arrayBuffer(); 75 | } else if (audioData instanceof ArrayBuffer) { 76 | if (fromSampleRate !== -1) { 77 | throw new Error( 78 | `Can not specify "fromSampleRate" when reading from ArrayBuffer`, 79 | ); 80 | } 81 | arrayBuffer = audioData; 82 | blob = new Blob([arrayBuffer], { type: 'audio/wav' }); 83 | } else { 84 | let float32Array; 85 | let data; 86 | if (audioData instanceof Int16Array) { 87 | data = audioData; 88 | float32Array = new Float32Array(audioData.length); 89 | for (let i = 0; i < audioData.length; i++) { 90 | float32Array[i] = audioData[i] / 0x8000; 91 | } 92 | } else if (audioData instanceof Float32Array) { 93 | float32Array = audioData; 94 | } else if (audioData instanceof Array) { 95 | float32Array = new Float32Array(audioData); 96 | } else { 97 | throw new Error( 98 | `"audioData" must be one of: Blob, Float32Arrray, Int16Array, ArrayBuffer, Array`, 99 | ); 100 | } 101 | if (fromSampleRate === -1) { 102 | throw new Error( 103 | `Must specify "fromSampleRate" when reading from Float32Array, In16Array or Array`, 104 | ); 105 | } else if (fromSampleRate < 3000) { 106 | throw new Error(`Minimum "fromSampleRate" is 3000 (3kHz)`); 107 | } 108 | if (!data) { 109 | data = WavPacker.floatTo16BitPCM(float32Array); 110 | } 111 | const audio = { 112 | bitsPerSample: 16, 113 | channels: [float32Array], 114 | data, 115 | }; 116 | const packer = new WavPacker(); 117 | const result = packer.pack(fromSampleRate, audio); 118 | blob = result.blob; 119 | arrayBuffer = await blob.arrayBuffer(); 120 | } 121 | const audioBuffer = await context.decodeAudioData(arrayBuffer); 122 | const values = audioBuffer.getChannelData(0); 123 | const url = URL.createObjectURL(blob); 124 | return { 125 | blob, 126 | url, 127 | values, 128 | audioBuffer, 129 | }; 130 | } 131 | 132 | /** 133 | * Logs data in debug mode 134 | * @param {...any} arguments 135 | * @returns {true} 136 | */ 137 | log() { 138 | if (this.debug) { 139 | this.log(...arguments); 140 | } 141 | return true; 142 | } 143 | 144 | /** 145 | * Retrieves the current sampleRate for the recorder 146 | * @returns {number} 147 | */ 148 | getSampleRate() { 149 | return this.sampleRate; 150 | } 151 | 152 | /** 153 | * Retrieves the current status of the recording 154 | * @returns {"ended"|"paused"|"recording"} 155 | */ 156 | getStatus() { 157 | if (!this.processor) { 158 | return 'ended'; 159 | } else if (!this.recording) { 160 | return 'paused'; 161 | } else { 162 | return 'recording'; 163 | } 164 | } 165 | 166 | /** 167 | * Sends an event to the AudioWorklet 168 | * @private 169 | * @param {string} name 170 | * @param {{[key: string]: any}} data 171 | * @param {AudioWorkletNode} [_processor] 172 | * @returns {Promise<{[key: string]: any}>} 173 | */ 174 | async _event(name, data = {}, _processor = null) { 175 | _processor = _processor || this.processor; 176 | if (!_processor) { 177 | throw new Error('Can not send events without recording first'); 178 | } 179 | const message = { 180 | event: name, 181 | id: this._lastEventId++, 182 | data, 183 | }; 184 | _processor.port.postMessage(message); 185 | const t0 = new Date().valueOf(); 186 | while (!this.eventReceipts[message.id]) { 187 | if (new Date().valueOf() - t0 > this.eventTimeout) { 188 | throw new Error(`Timeout waiting for "${name}" event`); 189 | } 190 | await new Promise((res) => setTimeout(() => res(true), 1)); 191 | } 192 | const payload = this.eventReceipts[message.id]; 193 | delete this.eventReceipts[message.id]; 194 | return payload; 195 | } 196 | 197 | /** 198 | * Sets device change callback, remove if callback provided is `null` 199 | * @param {(Array): void|null} callback 200 | * @returns {true} 201 | */ 202 | listenForDeviceChange(callback) { 203 | if (callback === null && this._deviceChangeCallback) { 204 | navigator.mediaDevices.removeEventListener( 205 | 'devicechange', 206 | this._deviceChangeCallback, 207 | ); 208 | this._deviceChangeCallback = null; 209 | } else if (callback !== null) { 210 | // Basically a debounce; we only want this called once when devices change 211 | // And we only want the most recent callback() to be executed 212 | // if a few are operating at the same time 213 | let lastId = 0; 214 | let lastDevices = []; 215 | const serializeDevices = (devices) => 216 | devices 217 | .map((d) => d.deviceId) 218 | .sort() 219 | .join(','); 220 | const cb = async () => { 221 | let id = ++lastId; 222 | const devices = await this.listDevices(); 223 | if (id === lastId) { 224 | if (serializeDevices(lastDevices) !== serializeDevices(devices)) { 225 | lastDevices = devices; 226 | callback(devices.slice()); 227 | } 228 | } 229 | }; 230 | navigator.mediaDevices.addEventListener('devicechange', cb); 231 | cb(); 232 | this._deviceChangeCallback = cb; 233 | } 234 | return true; 235 | } 236 | 237 | /** 238 | * Manually request permission to use the microphone 239 | * @returns {Promise} 240 | */ 241 | async requestPermission() { 242 | const permissionStatus = await navigator.permissions.query({ 243 | name: 'microphone', 244 | }); 245 | if (permissionStatus.state === 'denied') { 246 | window.alert('You must grant microphone access to use this feature.'); 247 | } else if (permissionStatus.state === 'prompt') { 248 | try { 249 | const stream = await navigator.mediaDevices.getUserMedia({ 250 | audio: true, 251 | }); 252 | const tracks = stream.getTracks(); 253 | tracks.forEach((track) => track.stop()); 254 | } catch (e) { 255 | window.alert('You must grant microphone access to use this feature.'); 256 | } 257 | } 258 | return true; 259 | } 260 | 261 | /** 262 | * List all eligible devices for recording, will request permission to use microphone 263 | * @returns {Promise>} 264 | */ 265 | async listDevices() { 266 | if ( 267 | !navigator.mediaDevices || 268 | !('enumerateDevices' in navigator.mediaDevices) 269 | ) { 270 | throw new Error('Could not request user devices'); 271 | } 272 | await this.requestPermission(); 273 | const devices = await navigator.mediaDevices.enumerateDevices(); 274 | const audioDevices = devices.filter( 275 | (device) => device.kind === 'audioinput', 276 | ); 277 | const defaultDeviceIndex = audioDevices.findIndex( 278 | (device) => device.deviceId === 'default', 279 | ); 280 | const deviceList = []; 281 | if (defaultDeviceIndex !== -1) { 282 | let defaultDevice = audioDevices.splice(defaultDeviceIndex, 1)[0]; 283 | let existingIndex = audioDevices.findIndex( 284 | (device) => device.groupId === defaultDevice.groupId, 285 | ); 286 | if (existingIndex !== -1) { 287 | defaultDevice = audioDevices.splice(existingIndex, 1)[0]; 288 | } 289 | defaultDevice.default = true; 290 | deviceList.push(defaultDevice); 291 | } 292 | return deviceList.concat(audioDevices); 293 | } 294 | 295 | /** 296 | * Begins a recording session and requests microphone permissions if not already granted 297 | * Microphone recording indicator will appear on browser tab but status will be "paused" 298 | * @param {string} [deviceId] if no device provided, default device will be used 299 | * @returns {Promise} 300 | */ 301 | async begin(deviceId) { 302 | if (this.processor) { 303 | throw new Error( 304 | `Already connected: please call .end() to start a new session`, 305 | ); 306 | } 307 | 308 | if ( 309 | !navigator.mediaDevices || 310 | !('getUserMedia' in navigator.mediaDevices) 311 | ) { 312 | throw new Error('Could not request user media'); 313 | } 314 | try { 315 | const config = { audio: true }; 316 | if (deviceId) { 317 | config.audio = { deviceId: { exact: deviceId } }; 318 | } 319 | this.stream = await navigator.mediaDevices.getUserMedia(config); 320 | } catch (err) { 321 | throw new Error('Could not start media stream'); 322 | } 323 | 324 | const context = new AudioContext({ sampleRate: this.sampleRate }); 325 | const source = context.createMediaStreamSource(this.stream); 326 | // Load and execute the module script. 327 | try { 328 | await context.audioWorklet.addModule(this.scriptSrc); 329 | } catch (e) { 330 | console.error(e); 331 | throw new Error(`Could not add audioWorklet module: ${this.scriptSrc}`); 332 | } 333 | const processor = new AudioWorkletNode(context, 'audio_processor'); 334 | processor.port.onmessage = (e) => { 335 | const { event, id, data } = e.data; 336 | if (event === 'receipt') { 337 | this.eventReceipts[id] = data; 338 | } else if (event === 'chunk') { 339 | if (this._chunkProcessorSize) { 340 | const buffer = this._chunkProcessorBuffer; 341 | this._chunkProcessorBuffer = { 342 | raw: WavPacker.mergeBuffers(buffer.raw, data.raw), 343 | mono: WavPacker.mergeBuffers(buffer.mono, data.mono), 344 | }; 345 | if ( 346 | this._chunkProcessorBuffer.mono.byteLength >= 347 | this._chunkProcessorSize 348 | ) { 349 | this._chunkProcessor(this._chunkProcessorBuffer); 350 | this._chunkProcessorBuffer = { 351 | raw: new ArrayBuffer(0), 352 | mono: new ArrayBuffer(0), 353 | }; 354 | } 355 | } else { 356 | this._chunkProcessor(data); 357 | } 358 | } 359 | }; 360 | 361 | const node = source.connect(processor); 362 | const analyser = context.createAnalyser(); 363 | analyser.fftSize = 8192; 364 | analyser.smoothingTimeConstant = 0.1; 365 | node.connect(analyser); 366 | if (this.outputToSpeakers) { 367 | // eslint-disable-next-line no-console 368 | console.warn( 369 | 'Warning: Output to speakers may affect sound quality,\n' + 370 | 'especially due to system audio feedback preventative measures.\n' + 371 | 'use only for debugging', 372 | ); 373 | analyser.connect(context.destination); 374 | } 375 | 376 | this.source = source; 377 | this.node = node; 378 | this.analyser = analyser; 379 | this.processor = processor; 380 | return true; 381 | } 382 | 383 | /** 384 | * Gets the current frequency domain data from the recording track 385 | * @param {"frequency"|"music"|"voice"} [analysisType] 386 | * @param {number} [minDecibels] default -100 387 | * @param {number} [maxDecibels] default -30 388 | * @returns {import('./analysis/audio_analysis.js').AudioAnalysisOutputType} 389 | */ 390 | getFrequencies( 391 | analysisType = 'frequency', 392 | minDecibels = -100, 393 | maxDecibels = -30, 394 | ) { 395 | if (!this.processor) { 396 | throw new Error('Session ended: please call .begin() first'); 397 | } 398 | return AudioAnalysis.getFrequencies( 399 | this.analyser, 400 | this.sampleRate, 401 | null, 402 | analysisType, 403 | minDecibels, 404 | maxDecibels, 405 | ); 406 | } 407 | 408 | /** 409 | * Pauses the recording 410 | * Keeps microphone stream open but halts storage of audio 411 | * @returns {Promise} 412 | */ 413 | async pause() { 414 | if (!this.processor) { 415 | throw new Error('Session ended: please call .begin() first'); 416 | } else if (!this.recording) { 417 | throw new Error('Already paused: please call .record() first'); 418 | } 419 | if (this._chunkProcessorBuffer.raw.byteLength) { 420 | this._chunkProcessor(this._chunkProcessorBuffer); 421 | } 422 | this.log('Pausing ...'); 423 | await this._event('stop'); 424 | this.recording = false; 425 | return true; 426 | } 427 | 428 | /** 429 | * Start recording stream and storing to memory from the connected audio source 430 | * @param {(data: { mono: Int16Array; raw: Int16Array }) => any} [chunkProcessor] 431 | * @param {number} [chunkSize] chunkProcessor will not be triggered until this size threshold met in mono audio 432 | * @returns {Promise} 433 | */ 434 | async record(chunkProcessor = () => {}, chunkSize = 8192) { 435 | if (!this.processor) { 436 | throw new Error('Session ended: please call .begin() first'); 437 | } else if (this.recording) { 438 | throw new Error('Already recording: please call .pause() first'); 439 | } else if (typeof chunkProcessor !== 'function') { 440 | throw new Error(`chunkProcessor must be a function`); 441 | } 442 | this._chunkProcessor = chunkProcessor; 443 | this._chunkProcessorSize = chunkSize; 444 | this._chunkProcessorBuffer = { 445 | raw: new ArrayBuffer(0), 446 | mono: new ArrayBuffer(0), 447 | }; 448 | this.log('Recording ...'); 449 | await this._event('start'); 450 | this.recording = true; 451 | return true; 452 | } 453 | 454 | /** 455 | * Clears the audio buffer, empties stored recording 456 | * @returns {Promise} 457 | */ 458 | async clear() { 459 | if (!this.processor) { 460 | throw new Error('Session ended: please call .begin() first'); 461 | } 462 | await this._event('clear'); 463 | return true; 464 | } 465 | 466 | /** 467 | * Reads the current audio stream data 468 | * @returns {Promise<{meanValues: Float32Array, channels: Array}>} 469 | */ 470 | async read() { 471 | if (!this.processor) { 472 | throw new Error('Session ended: please call .begin() first'); 473 | } 474 | this.log('Reading ...'); 475 | const result = await this._event('read'); 476 | return result; 477 | } 478 | 479 | /** 480 | * Saves the current audio stream to a file 481 | * @param {boolean} [force] Force saving while still recording 482 | * @returns {Promise} 483 | */ 484 | async save(force = false) { 485 | if (!this.processor) { 486 | throw new Error('Session ended: please call .begin() first'); 487 | } 488 | if (!force && this.recording) { 489 | throw new Error( 490 | 'Currently recording: please call .pause() first, or call .save(true) to force', 491 | ); 492 | } 493 | this.log('Exporting ...'); 494 | const exportData = await this._event('export'); 495 | const packer = new WavPacker(); 496 | const result = packer.pack(this.sampleRate, exportData.audio); 497 | return result; 498 | } 499 | 500 | /** 501 | * Ends the current recording session and saves the result 502 | * @returns {Promise} 503 | */ 504 | async end() { 505 | if (!this.processor) { 506 | throw new Error('Session ended: please call .begin() first'); 507 | } 508 | 509 | const _processor = this.processor; 510 | 511 | this.log('Stopping ...'); 512 | await this._event('stop'); 513 | this.recording = false; 514 | const tracks = this.stream.getTracks(); 515 | tracks.forEach((track) => track.stop()); 516 | 517 | this.log('Exporting ...'); 518 | const exportData = await this._event('export', {}, _processor); 519 | 520 | this.processor.disconnect(); 521 | this.source.disconnect(); 522 | this.node.disconnect(); 523 | this.analyser.disconnect(); 524 | this.stream = null; 525 | this.processor = null; 526 | this.source = null; 527 | this.node = null; 528 | 529 | const packer = new WavPacker(); 530 | const result = packer.pack(this.sampleRate, exportData.audio); 531 | return result; 532 | } 533 | 534 | /** 535 | * Performs a full cleanup of WavRecorder instance 536 | * Stops actively listening via microphone and removes existing listeners 537 | * @returns {Promise} 538 | */ 539 | async quit() { 540 | this.listenForDeviceChange(null); 541 | if (this.processor) { 542 | await this.end(); 543 | } 544 | return true; 545 | } 546 | } 547 | 548 | globalThis.WavRecorder = WavRecorder; 549 | -------------------------------------------------------------------------------- /script/wavtools.min.js: -------------------------------------------------------------------------------- 1 | (()=>{var h=class{static floatTo16BitPCM(e){let t=new ArrayBuffer(e.length*2),s=new DataView(t),n=0;for(let r=0;r>8]),new Uint8Array([t,t>>8,t>>16,t>>24])][e]}pack(e,t){if(t?.bitsPerSample)if(t?.channels){if(!t?.data)throw new Error('Missing "data"')}else throw new Error('Missing "channels"');else throw new Error('Missing "bitsPerSample"');let{bitsPerSample:s,channels:n,data:r}=t,a=["RIFF",this._packData(1,52),"WAVE","fmt ",this._packData(1,16),this._packData(0,1),this._packData(0,n.length),this._packData(1,e),this._packData(1,e*n.length*s/8),this._packData(0,n.length*s/8),this._packData(0,s),"data",this._packData(1,n[0].length*n.length*s/8),r],o=new Blob(a,{type:"audio/mpeg"}),u=URL.createObjectURL(o);return{blob:o,url:u,channelCount:n.length,sampleRate:e,duration:r.byteLength/(n.length*e*2)}}};globalThis.WavPacker=h;var I=[4186.01,4434.92,4698.63,4978.03,5274.04,5587.65,5919.91,6271.93,6644.88,7040,7458.62,7902.13],q=["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"],m=[],b=[];for(let l=1;l<=8;l++)for(let e=0;em[e]>A[0]&&m[e]m[e]>A[0]&&m[e]=0;y--)if(B>p[y]){d[y]=Math.max(d[y],F);break}}f=d,i=n==="voice"?C:m,c=n==="voice"?D:b}else f=Array.from(s),i=f.map((p,d)=>u*d),c=i.map(p=>`${p.toFixed(2)} Hz`);let w=f.map(p=>Math.max(0,Math.min((p-r)/(a-r),1)));return{values:new Float32Array(w),frequencies:i,labels:c}}constructor(e,t=null){if(this.fftResults=[],t){let{length:s,sampleRate:n}=t,r=new OfflineAudioContext({length:s,sampleRate:n}),a=r.createBufferSource();a.buffer=t;let o=r.createAnalyser();o.fftSize=8192,o.smoothingTimeConstant=.1,a.connect(o);let u=1/60,f=s/n,i=c=>{let w=u*c;w{let k=new Float32Array(o.frequencyBinCount);o.getFloatFrequencyData(k),this.fftResults.push(k),i(c+1)}),c===1?r.startRendering():r.resume()};a.start(0),i(1),this.audio=e,this.context=r,this.analyser=o,this.sampleRate=n,this.audioBuffer=t}else{let s=new AudioContext,n=s.createMediaElementSource(e),r=s.createAnalyser();r.fftSize=8192,r.smoothingTimeConstant=.1,n.connect(r),r.connect(s.destination),this.audio=e,this.context=s,this.analyser=r,this.sampleRate=this.context.sampleRate,this.audioBuffer=null}}getFrequencies(e="frequency",t=-100,s=-30){let n=null;if(this.audioBuffer&&this.fftResults.length){let r=this.audio.currentTime/this.audio.duration,a=Math.min(r*this.fftResults.length|0,this.fftResults.length-1);n=this.fftResults[a]}return l.getFrequencies(this.analyser,this.sampleRate,n,e,t,s)}async resumeIfSuspended(){return this.context.state==="suspended"&&await this.context.resume(),!0}};globalThis.AudioAnalysis=g;var L=` 2 | class StreamProcessor extends AudioWorkletProcessor { 3 | constructor() { 4 | super(); 5 | this.hasStarted = false; 6 | this.hasInterrupted = false; 7 | this.outputBuffers = []; 8 | this.bufferLength = 128; 9 | this.write = { buffer: new Float32Array(this.bufferLength), trackId: null }; 10 | this.writeOffset = 0; 11 | this.trackSampleOffsets = {}; 12 | this.port.onmessage = (event) => { 13 | if (event.data) { 14 | const payload = event.data; 15 | if (payload.event === 'write') { 16 | const int16Array = payload.buffer; 17 | const float32Array = new Float32Array(int16Array.length); 18 | for (let i = 0; i < int16Array.length; i++) { 19 | float32Array[i] = int16Array[i] / 0x8000; // Convert Int16 to Float32 20 | } 21 | this.writeData(float32Array, payload.trackId); 22 | } else if ( 23 | payload.event === 'offset' || 24 | payload.event === 'interrupt' 25 | ) { 26 | const requestId = payload.requestId; 27 | const trackId = this.write.trackId; 28 | const offset = this.trackSampleOffsets[trackId] || 0; 29 | this.port.postMessage({ 30 | event: 'offset', 31 | requestId, 32 | trackId, 33 | offset, 34 | }); 35 | if (payload.event === 'interrupt') { 36 | this.hasInterrupted = true; 37 | } 38 | } else { 39 | throw new Error(\`Unhandled event "\${payload.event}"\`); 40 | } 41 | } 42 | }; 43 | } 44 | 45 | writeData(float32Array, trackId = null) { 46 | let { buffer } = this.write; 47 | let offset = this.writeOffset; 48 | for (let i = 0; i < float32Array.length; i++) { 49 | buffer[offset++] = float32Array[i]; 50 | if (offset >= buffer.length) { 51 | this.outputBuffers.push(this.write); 52 | this.write = { buffer: new Float32Array(this.bufferLength), trackId }; 53 | buffer = this.write.buffer; 54 | offset = 0; 55 | } 56 | } 57 | this.writeOffset = offset; 58 | return true; 59 | } 60 | 61 | process(inputs, outputs, parameters) { 62 | const output = outputs[0]; 63 | const outputChannelData = output[0]; 64 | const outputBuffers = this.outputBuffers; 65 | if (this.hasInterrupted) { 66 | this.port.postMessage({ event: 'stop' }); 67 | return false; 68 | } else if (outputBuffers.length) { 69 | this.hasStarted = true; 70 | const { buffer, trackId } = outputBuffers.shift(); 71 | for (let i = 0; i < outputChannelData.length; i++) { 72 | outputChannelData[i] = buffer[i] || 0; 73 | } 74 | if (trackId) { 75 | this.trackSampleOffsets[trackId] = 76 | this.trackSampleOffsets[trackId] || 0; 77 | this.trackSampleOffsets[trackId] += buffer.length; 78 | } 79 | return true; 80 | } else if (this.hasStarted) { 81 | this.port.postMessage({ event: 'stop' }); 82 | return false; 83 | } else { 84 | return true; 85 | } 86 | } 87 | } 88 | 89 | registerProcessor('stream_processor', StreamProcessor); 90 | `,E=new Blob([L],{type:"application/javascript"}),R=URL.createObjectURL(E),P=R;var x=class{constructor({sampleRate:e=44100}={}){this.scriptSrc=P,this.sampleRate=e,this.context=null,this.stream=null,this.analyser=null,this.trackSampleOffsets={},this.interruptedTrackIds={}}async connect(){this.context=new AudioContext({sampleRate:this.sampleRate}),this.context.state==="suspended"&&await this.context.resume();try{await this.context.audioWorklet.addModule(this.scriptSrc)}catch(t){throw console.error(t),new Error(`Could not add audioWorklet module: ${this.scriptSrc}`)}let e=this.context.createAnalyser();return e.fftSize=8192,e.smoothingTimeConstant=.1,this.analyser=e,!0}getFrequencies(e="frequency",t=-100,s=-30){if(!this.analyser)throw new Error("Not connected, please call .connect() first");return g.getFrequencies(this.analyser,this.sampleRate,null,e,t,s)}_start(){let e=new AudioWorkletNode(this.context,"stream_processor");return e.connect(this.context.destination),e.port.onmessage=t=>{let{event:s}=t.data;if(s==="stop")e.disconnect(),this.stream=null;else if(s==="offset"){let{requestId:n,trackId:r,offset:a}=t.data,o=a/this.sampleRate;this.trackSampleOffsets[n]={trackId:r,offset:a,currentTime:o}}},this.analyser.disconnect(),e.connect(this.analyser),this.stream=e,!0}add16BitPCM(e,t="default"){if(typeof t!="string")throw new Error("trackId must be a string");if(this.interruptedTrackIds[t])return;this.stream||this._start();let s;if(e instanceof Int16Array)s=e;else if(e instanceof ArrayBuffer)s=new Int16Array(e);else throw new Error("argument must be Int16Array or ArrayBuffer");return this.stream.port.postMessage({event:"write",buffer:s,trackId:t}),s}async getTrackSampleOffset(e=!1){if(!this.stream)return null;let t=crypto.randomUUID();this.stream.port.postMessage({event:e?"interrupt":"offset",requestId:t});let s;for(;!s;)s=this.trackSampleOffsets[t],await new Promise(r=>setTimeout(()=>r(),1));let{trackId:n}=s;return e&&n&&(this.interruptedTrackIds[n]=!0),s}async interrupt(){return this.getTrackSampleOffset(!0)}};globalThis.WavStreamPlayer=x;var M=` 91 | class AudioProcessor extends AudioWorkletProcessor { 92 | 93 | constructor() { 94 | super(); 95 | this.port.onmessage = this.receive.bind(this); 96 | this.initialize(); 97 | } 98 | 99 | initialize() { 100 | this.foundAudio = false; 101 | this.recording = false; 102 | this.chunks = []; 103 | } 104 | 105 | /** 106 | * Concatenates sampled chunks into channels 107 | * Format is chunk[Left[], Right[]] 108 | */ 109 | readChannelData(chunks, channel = -1, maxChannels = 9) { 110 | let channelLimit; 111 | if (channel !== -1) { 112 | if (chunks[0] && chunks[0].length - 1 < channel) { 113 | throw new Error( 114 | \`Channel \${channel} out of range: max \${chunks[0].length}\` 115 | ); 116 | } 117 | channelLimit = channel + 1; 118 | } else { 119 | channel = 0; 120 | channelLimit = Math.min(chunks[0] ? chunks[0].length : 1, maxChannels); 121 | } 122 | const channels = []; 123 | for (let n = channel; n < channelLimit; n++) { 124 | const length = chunks.reduce((sum, chunk) => { 125 | return sum + chunk[n].length; 126 | }, 0); 127 | const buffers = chunks.map((chunk) => chunk[n]); 128 | const result = new Float32Array(length); 129 | let offset = 0; 130 | for (let i = 0; i < buffers.length; i++) { 131 | result.set(buffers[i], offset); 132 | offset += buffers[i].length; 133 | } 134 | channels[n] = result; 135 | } 136 | return channels; 137 | } 138 | 139 | /** 140 | * Combines parallel audio data into correct format, 141 | * channels[Left[], Right[]] to float32Array[LRLRLRLR...] 142 | */ 143 | formatAudioData(channels) { 144 | if (channels.length === 1) { 145 | // Simple case is only one channel 146 | const float32Array = channels[0].slice(); 147 | const meanValues = channels[0].slice(); 148 | return { float32Array, meanValues }; 149 | } else { 150 | const float32Array = new Float32Array( 151 | channels[0].length * channels.length 152 | ); 153 | const meanValues = new Float32Array(channels[0].length); 154 | for (let i = 0; i < channels[0].length; i++) { 155 | const offset = i * channels.length; 156 | let meanValue = 0; 157 | for (let n = 0; n < channels.length; n++) { 158 | float32Array[offset + n] = channels[n][i]; 159 | meanValue += channels[n][i]; 160 | } 161 | meanValues[i] = meanValue / channels.length; 162 | } 163 | return { float32Array, meanValues }; 164 | } 165 | } 166 | 167 | /** 168 | * Converts 32-bit float data to 16-bit integers 169 | */ 170 | floatTo16BitPCM(float32Array) { 171 | const buffer = new ArrayBuffer(float32Array.length * 2); 172 | const view = new DataView(buffer); 173 | let offset = 0; 174 | for (let i = 0; i < float32Array.length; i++, offset += 2) { 175 | let s = Math.max(-1, Math.min(1, float32Array[i])); 176 | view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); 177 | } 178 | return buffer; 179 | } 180 | 181 | /** 182 | * Retrieves the most recent amplitude values from the audio stream 183 | * @param {number} channel 184 | */ 185 | getValues(channel = -1) { 186 | const channels = this.readChannelData(this.chunks, channel); 187 | const { meanValues } = this.formatAudioData(channels); 188 | return { meanValues, channels }; 189 | } 190 | 191 | /** 192 | * Exports chunks as an audio/wav file 193 | */ 194 | export() { 195 | const channels = this.readChannelData(this.chunks); 196 | const { float32Array, meanValues } = this.formatAudioData(channels); 197 | const audioData = this.floatTo16BitPCM(float32Array); 198 | return { 199 | meanValues: meanValues, 200 | audio: { 201 | bitsPerSample: 16, 202 | channels: channels, 203 | data: audioData, 204 | }, 205 | }; 206 | } 207 | 208 | receive(e) { 209 | const { event, id } = e.data; 210 | let receiptData = {}; 211 | switch (event) { 212 | case 'start': 213 | this.recording = true; 214 | break; 215 | case 'stop': 216 | this.recording = false; 217 | break; 218 | case 'clear': 219 | this.initialize(); 220 | break; 221 | case 'export': 222 | receiptData = this.export(); 223 | break; 224 | case 'read': 225 | receiptData = this.getValues(); 226 | break; 227 | default: 228 | break; 229 | } 230 | // Always send back receipt 231 | this.port.postMessage({ event: 'receipt', id, data: receiptData }); 232 | } 233 | 234 | sendChunk(chunk) { 235 | const channels = this.readChannelData([chunk]); 236 | const { float32Array, meanValues } = this.formatAudioData(channels); 237 | const rawAudioData = this.floatTo16BitPCM(float32Array); 238 | const monoAudioData = this.floatTo16BitPCM(meanValues); 239 | this.port.postMessage({ 240 | event: 'chunk', 241 | data: { 242 | mono: monoAudioData, 243 | raw: rawAudioData, 244 | }, 245 | }); 246 | } 247 | 248 | process(inputList, outputList, parameters) { 249 | // Copy input to output (e.g. speakers) 250 | // Note that this creates choppy sounds with Mac products 251 | const sourceLimit = Math.min(inputList.length, outputList.length); 252 | for (let inputNum = 0; inputNum < sourceLimit; inputNum++) { 253 | const input = inputList[inputNum]; 254 | const output = outputList[inputNum]; 255 | const channelCount = Math.min(input.length, output.length); 256 | for (let channelNum = 0; channelNum < channelCount; channelNum++) { 257 | input[channelNum].forEach((sample, i) => { 258 | output[channelNum][i] = sample; 259 | }); 260 | } 261 | } 262 | const inputs = inputList[0]; 263 | // There's latency at the beginning of a stream before recording starts 264 | // Make sure we actually receive audio data before we start storing chunks 265 | let sliceIndex = 0; 266 | if (!this.foundAudio) { 267 | for (const channel of inputs) { 268 | sliceIndex = 0; // reset for each channel 269 | if (this.foundAudio) { 270 | break; 271 | } 272 | if (channel) { 273 | for (const value of channel) { 274 | if (value !== 0) { 275 | // find only one non-zero entry in any channel 276 | this.foundAudio = true; 277 | break; 278 | } else { 279 | sliceIndex++; 280 | } 281 | } 282 | } 283 | } 284 | } 285 | if (inputs && inputs[0] && this.foundAudio && this.recording) { 286 | // We need to copy the TypedArray, because the \`process\` 287 | // internals will reuse the same buffer to hold each input 288 | const chunk = inputs.map((input) => input.slice(sliceIndex)); 289 | this.chunks.push(chunk); 290 | this.sendChunk(chunk); 291 | } 292 | return true; 293 | } 294 | } 295 | 296 | registerProcessor('audio_processor', AudioProcessor); 297 | `,T=new Blob([M],{type:"application/javascript"}),O=URL.createObjectURL(T),_=O;var S=class{constructor({sampleRate:e=44100,outputToSpeakers:t=!1,debug:s=!1}={}){this.scriptSrc=_,this.sampleRate=e,this.outputToSpeakers=t,this.debug=!!s,this._deviceChangeCallback=null,this._devices=[],this.stream=null,this.processor=null,this.source=null,this.node=null,this.recording=!1,this._lastEventId=0,this.eventReceipts={},this.eventTimeout=5e3,this._chunkProcessor=()=>{},this._chunkProcessorSize=void 0,this._chunkProcessorBuffer={raw:new ArrayBuffer(0),mono:new ArrayBuffer(0)}}static async decode(e,t=44100,s=-1){let n=new AudioContext({sampleRate:t}),r,a;if(e instanceof Blob){if(s!==-1)throw new Error('Can not specify "fromSampleRate" when reading from Blob');a=e,r=await a.arrayBuffer()}else if(e instanceof ArrayBuffer){if(s!==-1)throw new Error('Can not specify "fromSampleRate" when reading from ArrayBuffer');r=e,a=new Blob([r],{type:"audio/wav"})}else{let i,c;if(e instanceof Int16Array){c=e,i=new Float32Array(e.length);for(let d=0;d');if(s===-1)throw new Error('Must specify "fromSampleRate" when reading from Float32Array, In16Array or Array');if(s<3e3)throw new Error('Minimum "fromSampleRate" is 3000 (3kHz)');c||(c=h.floatTo16BitPCM(i));let w={bitsPerSample:16,channels:[i],data:c};a=new h().pack(s,w).blob,r=await a.arrayBuffer()}let o=await n.decodeAudioData(r),u=o.getChannelData(0),f=URL.createObjectURL(a);return{blob:a,url:f,values:u,audioBuffer:o}}log(){return this.debug&&this.log(...arguments),!0}getSampleRate(){return this.sampleRate}getStatus(){return this.processor?this.recording?"recording":"paused":"ended"}async _event(e,t={},s=null){if(s=s||this.processor,!s)throw new Error("Can not send events without recording first");let n={event:e,id:this._lastEventId++,data:t};s.port.postMessage(n);let r=new Date().valueOf();for(;!this.eventReceipts[n.id];){if(new Date().valueOf()-r>this.eventTimeout)throw new Error(`Timeout waiting for "${e}" event`);await new Promise(o=>setTimeout(()=>o(!0),1))}let a=this.eventReceipts[n.id];return delete this.eventReceipts[n.id],a}listenForDeviceChange(e){if(e===null&&this._deviceChangeCallback)navigator.mediaDevices.removeEventListener("devicechange",this._deviceChangeCallback),this._deviceChangeCallback=null;else if(e!==null){let t=0,s=[],n=a=>a.map(o=>o.deviceId).sort().join(","),r=async()=>{let a=++t,o=await this.listDevices();a===t&&n(s)!==n(o)&&(s=o,e(o.slice()))};navigator.mediaDevices.addEventListener("devicechange",r),r(),this._deviceChangeCallback=r}return!0}async requestPermission(){let e=await navigator.permissions.query({name:"microphone"});if(e.state==="denied")window.alert("You must grant microphone access to use this feature.");else if(e.state==="prompt")try{(await navigator.mediaDevices.getUserMedia({audio:!0})).getTracks().forEach(n=>n.stop())}catch{window.alert("You must grant microphone access to use this feature.")}return!0}async listDevices(){if(!navigator.mediaDevices||!("enumerateDevices"in navigator.mediaDevices))throw new Error("Could not request user devices");await this.requestPermission();let t=(await navigator.mediaDevices.enumerateDevices()).filter(r=>r.kind==="audioinput"),s=t.findIndex(r=>r.deviceId==="default"),n=[];if(s!==-1){let r=t.splice(s,1)[0],a=t.findIndex(o=>o.groupId===r.groupId);a!==-1&&(r=t.splice(a,1)[0]),r.default=!0,n.push(r)}return n.concat(t)}async begin(e){if(this.processor)throw new Error("Already connected: please call .end() to start a new session");if(!navigator.mediaDevices||!("getUserMedia"in navigator.mediaDevices))throw new Error("Could not request user media");try{let o={audio:!0};e&&(o.audio={deviceId:{exact:e}}),this.stream=await navigator.mediaDevices.getUserMedia(o)}catch{throw new Error("Could not start media stream")}let t=new AudioContext({sampleRate:this.sampleRate}),s=t.createMediaStreamSource(this.stream);try{await t.audioWorklet.addModule(this.scriptSrc)}catch(o){throw console.error(o),new Error(`Could not add audioWorklet module: ${this.scriptSrc}`)}let n=new AudioWorkletNode(t,"audio_processor");n.port.onmessage=o=>{let{event:u,id:f,data:i}=o.data;if(u==="receipt")this.eventReceipts[f]=i;else if(u==="chunk")if(this._chunkProcessorSize){let c=this._chunkProcessorBuffer;this._chunkProcessorBuffer={raw:h.mergeBuffers(c.raw,i.raw),mono:h.mergeBuffers(c.mono,i.mono)},this._chunkProcessorBuffer.mono.byteLength>=this._chunkProcessorSize&&(this._chunkProcessor(this._chunkProcessorBuffer),this._chunkProcessorBuffer={raw:new ArrayBuffer(0),mono:new ArrayBuffer(0)})}else this._chunkProcessor(i)};let r=s.connect(n),a=t.createAnalyser();return a.fftSize=8192,a.smoothingTimeConstant=.1,r.connect(a),this.outputToSpeakers&&(console.warn(`Warning: Output to speakers may affect sound quality, 298 | especially due to system audio feedback preventative measures. 299 | use only for debugging`),a.connect(t.destination)),this.source=s,this.node=r,this.analyser=a,this.processor=n,!0}getFrequencies(e="frequency",t=-100,s=-30){if(!this.processor)throw new Error("Session ended: please call .begin() first");return g.getFrequencies(this.analyser,this.sampleRate,null,e,t,s)}async pause(){if(this.processor){if(!this.recording)throw new Error("Already paused: please call .record() first")}else throw new Error("Session ended: please call .begin() first");return this._chunkProcessorBuffer.raw.byteLength&&this._chunkProcessor(this._chunkProcessorBuffer),this.log("Pausing ..."),await this._event("stop"),this.recording=!1,!0}async record(e=()=>{},t=8192){if(this.processor){if(this.recording)throw new Error("Already recording: please call .pause() first");if(typeof e!="function")throw new Error("chunkProcessor must be a function")}else throw new Error("Session ended: please call .begin() first");return this._chunkProcessor=e,this._chunkProcessorSize=t,this._chunkProcessorBuffer={raw:new ArrayBuffer(0),mono:new ArrayBuffer(0)},this.log("Recording ..."),await this._event("start"),this.recording=!0,!0}async clear(){if(!this.processor)throw new Error("Session ended: please call .begin() first");return await this._event("clear"),!0}async read(){if(!this.processor)throw new Error("Session ended: please call .begin() first");return this.log("Reading ..."),await this._event("read")}async save(e=!1){if(!this.processor)throw new Error("Session ended: please call .begin() first");if(!e&&this.recording)throw new Error("Currently recording: please call .pause() first, or call .save(true) to force");this.log("Exporting ...");let t=await this._event("export");return new h().pack(this.sampleRate,t.audio)}async end(){if(!this.processor)throw new Error("Session ended: please call .begin() first");let e=this.processor;this.log("Stopping ..."),await this._event("stop"),this.recording=!1,this.stream.getTracks().forEach(a=>a.stop()),this.log("Exporting ...");let s=await this._event("export",{},e);return this.processor.disconnect(),this.source.disconnect(),this.node.disconnect(),this.analyser.disconnect(),this.stream=null,this.processor=null,this.source=null,this.node=null,new h().pack(this.sampleRate,s.audio)}async quit(){return this.listenForDeviceChange(null),this.processor&&await this.end(),!0}};globalThis.WavRecorder=S;})(); 300 | -------------------------------------------------------------------------------- /script/wavtools.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | // lib/wav_packer.js 3 | var WavPacker = class { 4 | /** 5 | * Converts Float32Array of amplitude data to ArrayBuffer in Int16Array format 6 | * @param {Float32Array} float32Array 7 | * @returns {ArrayBuffer} 8 | */ 9 | static floatTo16BitPCM(float32Array) { 10 | const buffer = new ArrayBuffer(float32Array.length * 2); 11 | const view = new DataView(buffer); 12 | let offset = 0; 13 | for (let i = 0; i < float32Array.length; i++, offset += 2) { 14 | let s = Math.max(-1, Math.min(1, float32Array[i])); 15 | view.setInt16(offset, s < 0 ? s * 32768 : s * 32767, true); 16 | } 17 | return buffer; 18 | } 19 | /** 20 | * Concatenates two ArrayBuffers 21 | * @param {ArrayBuffer} leftBuffer 22 | * @param {ArrayBuffer} rightBuffer 23 | * @returns {ArrayBuffer} 24 | */ 25 | static mergeBuffers(leftBuffer, rightBuffer) { 26 | const tmpArray = new Uint8Array( 27 | leftBuffer.byteLength + rightBuffer.byteLength 28 | ); 29 | tmpArray.set(new Uint8Array(leftBuffer), 0); 30 | tmpArray.set(new Uint8Array(rightBuffer), leftBuffer.byteLength); 31 | return tmpArray.buffer; 32 | } 33 | /** 34 | * Packs data into an Int16 format 35 | * @private 36 | * @param {number} size 0 = 1x Int16, 1 = 2x Int16 37 | * @param {number} arg value to pack 38 | * @returns 39 | */ 40 | _packData(size, arg) { 41 | return [ 42 | new Uint8Array([arg, arg >> 8]), 43 | new Uint8Array([arg, arg >> 8, arg >> 16, arg >> 24]) 44 | ][size]; 45 | } 46 | /** 47 | * Packs audio into "audio/wav" Blob 48 | * @param {number} sampleRate 49 | * @param {{bitsPerSample: number, channels: Array, data: Int16Array}} audio 50 | * @returns {WavPackerAudioType} 51 | */ 52 | pack(sampleRate, audio) { 53 | if (!audio?.bitsPerSample) { 54 | throw new Error(`Missing "bitsPerSample"`); 55 | } else if (!audio?.channels) { 56 | throw new Error(`Missing "channels"`); 57 | } else if (!audio?.data) { 58 | throw new Error(`Missing "data"`); 59 | } 60 | const { bitsPerSample, channels, data } = audio; 61 | const output = [ 62 | // Header 63 | "RIFF", 64 | this._packData( 65 | 1, 66 | 4 + (8 + 24) + (8 + 8) 67 | /* chunk 2 length */ 68 | ), 69 | // Length 70 | "WAVE", 71 | // chunk 1 72 | "fmt ", 73 | // Sub-chunk identifier 74 | this._packData(1, 16), 75 | // Chunk length 76 | this._packData(0, 1), 77 | // Audio format (1 is linear quantization) 78 | this._packData(0, channels.length), 79 | this._packData(1, sampleRate), 80 | this._packData(1, sampleRate * channels.length * bitsPerSample / 8), 81 | // Byte rate 82 | this._packData(0, channels.length * bitsPerSample / 8), 83 | this._packData(0, bitsPerSample), 84 | // chunk 2 85 | "data", 86 | // Sub-chunk identifier 87 | this._packData( 88 | 1, 89 | channels[0].length * channels.length * bitsPerSample / 8 90 | ), 91 | // Chunk length 92 | data 93 | ]; 94 | const blob = new Blob(output, { type: "audio/mpeg" }); 95 | const url = URL.createObjectURL(blob); 96 | return { 97 | blob, 98 | url, 99 | channelCount: channels.length, 100 | sampleRate, 101 | duration: data.byteLength / (channels.length * sampleRate * 2) 102 | }; 103 | } 104 | }; 105 | globalThis.WavPacker = WavPacker; 106 | 107 | // lib/analysis/constants.js 108 | var octave8Frequencies = [ 109 | 4186.01, 110 | 4434.92, 111 | 4698.63, 112 | 4978.03, 113 | 5274.04, 114 | 5587.65, 115 | 5919.91, 116 | 6271.93, 117 | 6644.88, 118 | 7040, 119 | 7458.62, 120 | 7902.13 121 | ]; 122 | var octave8FrequencyLabels = [ 123 | "C", 124 | "C#", 125 | "D", 126 | "D#", 127 | "E", 128 | "F", 129 | "F#", 130 | "G", 131 | "G#", 132 | "A", 133 | "A#", 134 | "B" 135 | ]; 136 | var noteFrequencies = []; 137 | var noteFrequencyLabels = []; 138 | for (let i = 1; i <= 8; i++) { 139 | for (let f = 0; f < octave8Frequencies.length; f++) { 140 | const freq = octave8Frequencies[f]; 141 | noteFrequencies.push(freq / Math.pow(2, 8 - i)); 142 | noteFrequencyLabels.push(octave8FrequencyLabels[f] + i); 143 | } 144 | } 145 | var voiceFrequencyRange = [32, 2e3]; 146 | var voiceFrequencies = noteFrequencies.filter((_, i) => { 147 | return noteFrequencies[i] > voiceFrequencyRange[0] && noteFrequencies[i] < voiceFrequencyRange[1]; 148 | }); 149 | var voiceFrequencyLabels = noteFrequencyLabels.filter((_, i) => { 150 | return noteFrequencies[i] > voiceFrequencyRange[0] && noteFrequencies[i] < voiceFrequencyRange[1]; 151 | }); 152 | 153 | // lib/analysis/audio_analysis.js 154 | var AudioAnalysis = class _AudioAnalysis { 155 | /** 156 | * Retrieves frequency domain data from an AnalyserNode adjusted to a decibel range 157 | * returns human-readable formatting and labels 158 | * @param {AnalyserNode} analyser 159 | * @param {number} sampleRate 160 | * @param {Float32Array} [fftResult] 161 | * @param {"frequency"|"music"|"voice"} [analysisType] 162 | * @param {number} [minDecibels] default -100 163 | * @param {number} [maxDecibels] default -30 164 | * @returns {AudioAnalysisOutputType} 165 | */ 166 | static getFrequencies(analyser, sampleRate, fftResult, analysisType = "frequency", minDecibels = -100, maxDecibels = -30) { 167 | if (!fftResult) { 168 | fftResult = new Float32Array(analyser.frequencyBinCount); 169 | analyser.getFloatFrequencyData(fftResult); 170 | } 171 | const nyquistFrequency = sampleRate / 2; 172 | const frequencyStep = 1 / fftResult.length * nyquistFrequency; 173 | let outputValues; 174 | let frequencies; 175 | let labels; 176 | if (analysisType === "music" || analysisType === "voice") { 177 | const useFrequencies = analysisType === "voice" ? voiceFrequencies : noteFrequencies; 178 | const aggregateOutput = Array(useFrequencies.length).fill(minDecibels); 179 | for (let i = 0; i < fftResult.length; i++) { 180 | const frequency = i * frequencyStep; 181 | const amplitude = fftResult[i]; 182 | for (let n = useFrequencies.length - 1; n >= 0; n--) { 183 | if (frequency > useFrequencies[n]) { 184 | aggregateOutput[n] = Math.max(aggregateOutput[n], amplitude); 185 | break; 186 | } 187 | } 188 | } 189 | outputValues = aggregateOutput; 190 | frequencies = analysisType === "voice" ? voiceFrequencies : noteFrequencies; 191 | labels = analysisType === "voice" ? voiceFrequencyLabels : noteFrequencyLabels; 192 | } else { 193 | outputValues = Array.from(fftResult); 194 | frequencies = outputValues.map((_, i) => frequencyStep * i); 195 | labels = frequencies.map((f) => `${f.toFixed(2)} Hz`); 196 | } 197 | const normalizedOutput = outputValues.map((v) => { 198 | return Math.max( 199 | 0, 200 | Math.min((v - minDecibels) / (maxDecibels - minDecibels), 1) 201 | ); 202 | }); 203 | const values = new Float32Array(normalizedOutput); 204 | return { 205 | values, 206 | frequencies, 207 | labels 208 | }; 209 | } 210 | /** 211 | * Creates a new AudioAnalysis instance for an HTMLAudioElement 212 | * @param {HTMLAudioElement} audioElement 213 | * @param {AudioBuffer|null} [audioBuffer] If provided, will cache all frequency domain data from the buffer 214 | * @returns {AudioAnalysis} 215 | */ 216 | constructor(audioElement, audioBuffer = null) { 217 | this.fftResults = []; 218 | if (audioBuffer) { 219 | const { length, sampleRate } = audioBuffer; 220 | const offlineAudioContext = new OfflineAudioContext({ 221 | length, 222 | sampleRate 223 | }); 224 | const source = offlineAudioContext.createBufferSource(); 225 | source.buffer = audioBuffer; 226 | const analyser = offlineAudioContext.createAnalyser(); 227 | analyser.fftSize = 8192; 228 | analyser.smoothingTimeConstant = 0.1; 229 | source.connect(analyser); 230 | const renderQuantumInSeconds = 1 / 60; 231 | const durationInSeconds = length / sampleRate; 232 | const analyze = (index) => { 233 | const suspendTime = renderQuantumInSeconds * index; 234 | if (suspendTime < durationInSeconds) { 235 | offlineAudioContext.suspend(suspendTime).then(() => { 236 | const fftResult = new Float32Array(analyser.frequencyBinCount); 237 | analyser.getFloatFrequencyData(fftResult); 238 | this.fftResults.push(fftResult); 239 | analyze(index + 1); 240 | }); 241 | } 242 | if (index === 1) { 243 | offlineAudioContext.startRendering(); 244 | } else { 245 | offlineAudioContext.resume(); 246 | } 247 | }; 248 | source.start(0); 249 | analyze(1); 250 | this.audio = audioElement; 251 | this.context = offlineAudioContext; 252 | this.analyser = analyser; 253 | this.sampleRate = sampleRate; 254 | this.audioBuffer = audioBuffer; 255 | } else { 256 | const audioContext = new AudioContext(); 257 | const track = audioContext.createMediaElementSource(audioElement); 258 | const analyser = audioContext.createAnalyser(); 259 | analyser.fftSize = 8192; 260 | analyser.smoothingTimeConstant = 0.1; 261 | track.connect(analyser); 262 | analyser.connect(audioContext.destination); 263 | this.audio = audioElement; 264 | this.context = audioContext; 265 | this.analyser = analyser; 266 | this.sampleRate = this.context.sampleRate; 267 | this.audioBuffer = null; 268 | } 269 | } 270 | /** 271 | * Gets the current frequency domain data from the playing audio track 272 | * @param {"frequency"|"music"|"voice"} [analysisType] 273 | * @param {number} [minDecibels] default -100 274 | * @param {number} [maxDecibels] default -30 275 | * @returns {AudioAnalysisOutputType} 276 | */ 277 | getFrequencies(analysisType = "frequency", minDecibels = -100, maxDecibels = -30) { 278 | let fftResult = null; 279 | if (this.audioBuffer && this.fftResults.length) { 280 | const pct = this.audio.currentTime / this.audio.duration; 281 | const index = Math.min( 282 | pct * this.fftResults.length | 0, 283 | this.fftResults.length - 1 284 | ); 285 | fftResult = this.fftResults[index]; 286 | } 287 | return _AudioAnalysis.getFrequencies( 288 | this.analyser, 289 | this.sampleRate, 290 | fftResult, 291 | analysisType, 292 | minDecibels, 293 | maxDecibels 294 | ); 295 | } 296 | /** 297 | * Resume the internal AudioContext if it was suspended due to the lack of 298 | * user interaction when the AudioAnalysis was instantiated. 299 | * @returns {Promise} 300 | */ 301 | async resumeIfSuspended() { 302 | if (this.context.state === "suspended") { 303 | await this.context.resume(); 304 | } 305 | return true; 306 | } 307 | }; 308 | globalThis.AudioAnalysis = AudioAnalysis; 309 | 310 | // lib/worklets/stream_processor.js 311 | var StreamProcessorWorklet = ` 312 | class StreamProcessor extends AudioWorkletProcessor { 313 | constructor() { 314 | super(); 315 | this.hasStarted = false; 316 | this.hasInterrupted = false; 317 | this.outputBuffers = []; 318 | this.bufferLength = 128; 319 | this.write = { buffer: new Float32Array(this.bufferLength), trackId: null }; 320 | this.writeOffset = 0; 321 | this.trackSampleOffsets = {}; 322 | this.port.onmessage = (event) => { 323 | if (event.data) { 324 | const payload = event.data; 325 | if (payload.event === 'write') { 326 | const int16Array = payload.buffer; 327 | const float32Array = new Float32Array(int16Array.length); 328 | for (let i = 0; i < int16Array.length; i++) { 329 | float32Array[i] = int16Array[i] / 0x8000; // Convert Int16 to Float32 330 | } 331 | this.writeData(float32Array, payload.trackId); 332 | } else if ( 333 | payload.event === 'offset' || 334 | payload.event === 'interrupt' 335 | ) { 336 | const requestId = payload.requestId; 337 | const trackId = this.write.trackId; 338 | const offset = this.trackSampleOffsets[trackId] || 0; 339 | this.port.postMessage({ 340 | event: 'offset', 341 | requestId, 342 | trackId, 343 | offset, 344 | }); 345 | if (payload.event === 'interrupt') { 346 | this.hasInterrupted = true; 347 | } 348 | } else { 349 | throw new Error(\`Unhandled event "\${payload.event}"\`); 350 | } 351 | } 352 | }; 353 | } 354 | 355 | writeData(float32Array, trackId = null) { 356 | let { buffer } = this.write; 357 | let offset = this.writeOffset; 358 | for (let i = 0; i < float32Array.length; i++) { 359 | buffer[offset++] = float32Array[i]; 360 | if (offset >= buffer.length) { 361 | this.outputBuffers.push(this.write); 362 | this.write = { buffer: new Float32Array(this.bufferLength), trackId }; 363 | buffer = this.write.buffer; 364 | offset = 0; 365 | } 366 | } 367 | this.writeOffset = offset; 368 | return true; 369 | } 370 | 371 | process(inputs, outputs, parameters) { 372 | const output = outputs[0]; 373 | const outputChannelData = output[0]; 374 | const outputBuffers = this.outputBuffers; 375 | if (this.hasInterrupted) { 376 | this.port.postMessage({ event: 'stop' }); 377 | return false; 378 | } else if (outputBuffers.length) { 379 | this.hasStarted = true; 380 | const { buffer, trackId } = outputBuffers.shift(); 381 | for (let i = 0; i < outputChannelData.length; i++) { 382 | outputChannelData[i] = buffer[i] || 0; 383 | } 384 | if (trackId) { 385 | this.trackSampleOffsets[trackId] = 386 | this.trackSampleOffsets[trackId] || 0; 387 | this.trackSampleOffsets[trackId] += buffer.length; 388 | } 389 | return true; 390 | } else if (this.hasStarted) { 391 | this.port.postMessage({ event: 'stop' }); 392 | return false; 393 | } else { 394 | return true; 395 | } 396 | } 397 | } 398 | 399 | registerProcessor('stream_processor', StreamProcessor); 400 | `; 401 | var script = new Blob([StreamProcessorWorklet], { 402 | type: "application/javascript" 403 | }); 404 | var src = URL.createObjectURL(script); 405 | var StreamProcessorSrc = src; 406 | 407 | // lib/wav_stream_player.js 408 | var WavStreamPlayer = class { 409 | /** 410 | * Creates a new WavStreamPlayer instance 411 | * @param {{sampleRate?: number}} options 412 | * @returns {WavStreamPlayer} 413 | */ 414 | constructor({ sampleRate = 44100 } = {}) { 415 | this.scriptSrc = StreamProcessorSrc; 416 | this.sampleRate = sampleRate; 417 | this.context = null; 418 | this.stream = null; 419 | this.analyser = null; 420 | this.trackSampleOffsets = {}; 421 | this.interruptedTrackIds = {}; 422 | } 423 | /** 424 | * Connects the audio context and enables output to speakers 425 | * @returns {Promise} 426 | */ 427 | async connect() { 428 | this.context = new AudioContext({ sampleRate: this.sampleRate }); 429 | if (this.context.state === "suspended") { 430 | await this.context.resume(); 431 | } 432 | try { 433 | await this.context.audioWorklet.addModule(this.scriptSrc); 434 | } catch (e) { 435 | console.error(e); 436 | throw new Error(`Could not add audioWorklet module: ${this.scriptSrc}`); 437 | } 438 | const analyser = this.context.createAnalyser(); 439 | analyser.fftSize = 8192; 440 | analyser.smoothingTimeConstant = 0.1; 441 | this.analyser = analyser; 442 | return true; 443 | } 444 | /** 445 | * Gets the current frequency domain data from the playing track 446 | * @param {"frequency"|"music"|"voice"} [analysisType] 447 | * @param {number} [minDecibels] default -100 448 | * @param {number} [maxDecibels] default -30 449 | * @returns {import('./analysis/audio_analysis.js').AudioAnalysisOutputType} 450 | */ 451 | getFrequencies(analysisType = "frequency", minDecibels = -100, maxDecibels = -30) { 452 | if (!this.analyser) { 453 | throw new Error("Not connected, please call .connect() first"); 454 | } 455 | return AudioAnalysis.getFrequencies( 456 | this.analyser, 457 | this.sampleRate, 458 | null, 459 | analysisType, 460 | minDecibels, 461 | maxDecibels 462 | ); 463 | } 464 | /** 465 | * Starts audio streaming 466 | * @private 467 | * @returns {Promise} 468 | */ 469 | _start() { 470 | const streamNode = new AudioWorkletNode(this.context, "stream_processor"); 471 | streamNode.connect(this.context.destination); 472 | streamNode.port.onmessage = (e) => { 473 | const { event } = e.data; 474 | if (event === "stop") { 475 | streamNode.disconnect(); 476 | this.stream = null; 477 | } else if (event === "offset") { 478 | const { requestId, trackId, offset } = e.data; 479 | const currentTime = offset / this.sampleRate; 480 | this.trackSampleOffsets[requestId] = { trackId, offset, currentTime }; 481 | } 482 | }; 483 | this.analyser.disconnect(); 484 | streamNode.connect(this.analyser); 485 | this.stream = streamNode; 486 | return true; 487 | } 488 | /** 489 | * Adds 16BitPCM data to the currently playing audio stream 490 | * You can add chunks beyond the current play point and they will be queued for play 491 | * @param {ArrayBuffer|Int16Array} arrayBuffer 492 | * @param {string} [trackId] 493 | * @returns {Int16Array} 494 | */ 495 | add16BitPCM(arrayBuffer, trackId = "default") { 496 | if (typeof trackId !== "string") { 497 | throw new Error(`trackId must be a string`); 498 | } else if (this.interruptedTrackIds[trackId]) { 499 | return; 500 | } 501 | if (!this.stream) { 502 | this._start(); 503 | } 504 | let buffer; 505 | if (arrayBuffer instanceof Int16Array) { 506 | buffer = arrayBuffer; 507 | } else if (arrayBuffer instanceof ArrayBuffer) { 508 | buffer = new Int16Array(arrayBuffer); 509 | } else { 510 | throw new Error(`argument must be Int16Array or ArrayBuffer`); 511 | } 512 | this.stream.port.postMessage({ event: "write", buffer, trackId }); 513 | return buffer; 514 | } 515 | /** 516 | * Gets the offset (sample count) of the currently playing stream 517 | * @param {boolean} [interrupt] 518 | * @returns {{trackId: string|null, offset: number, currentTime: number}} 519 | */ 520 | async getTrackSampleOffset(interrupt = false) { 521 | if (!this.stream) { 522 | return null; 523 | } 524 | const requestId = crypto.randomUUID(); 525 | this.stream.port.postMessage({ 526 | event: interrupt ? "interrupt" : "offset", 527 | requestId 528 | }); 529 | let trackSampleOffset; 530 | while (!trackSampleOffset) { 531 | trackSampleOffset = this.trackSampleOffsets[requestId]; 532 | await new Promise((r) => setTimeout(() => r(), 1)); 533 | } 534 | const { trackId } = trackSampleOffset; 535 | if (interrupt && trackId) { 536 | this.interruptedTrackIds[trackId] = true; 537 | } 538 | return trackSampleOffset; 539 | } 540 | /** 541 | * Strips the current stream and returns the sample offset of the audio 542 | * @param {boolean} [interrupt] 543 | * @returns {{trackId: string|null, offset: number, currentTime: number}} 544 | */ 545 | async interrupt() { 546 | return this.getTrackSampleOffset(true); 547 | } 548 | }; 549 | globalThis.WavStreamPlayer = WavStreamPlayer; 550 | 551 | // lib/worklets/audio_processor.js 552 | var AudioProcessorWorklet = ` 553 | class AudioProcessor extends AudioWorkletProcessor { 554 | 555 | constructor() { 556 | super(); 557 | this.port.onmessage = this.receive.bind(this); 558 | this.initialize(); 559 | } 560 | 561 | initialize() { 562 | this.foundAudio = false; 563 | this.recording = false; 564 | this.chunks = []; 565 | } 566 | 567 | /** 568 | * Concatenates sampled chunks into channels 569 | * Format is chunk[Left[], Right[]] 570 | */ 571 | readChannelData(chunks, channel = -1, maxChannels = 9) { 572 | let channelLimit; 573 | if (channel !== -1) { 574 | if (chunks[0] && chunks[0].length - 1 < channel) { 575 | throw new Error( 576 | \`Channel \${channel} out of range: max \${chunks[0].length}\` 577 | ); 578 | } 579 | channelLimit = channel + 1; 580 | } else { 581 | channel = 0; 582 | channelLimit = Math.min(chunks[0] ? chunks[0].length : 1, maxChannels); 583 | } 584 | const channels = []; 585 | for (let n = channel; n < channelLimit; n++) { 586 | const length = chunks.reduce((sum, chunk) => { 587 | return sum + chunk[n].length; 588 | }, 0); 589 | const buffers = chunks.map((chunk) => chunk[n]); 590 | const result = new Float32Array(length); 591 | let offset = 0; 592 | for (let i = 0; i < buffers.length; i++) { 593 | result.set(buffers[i], offset); 594 | offset += buffers[i].length; 595 | } 596 | channels[n] = result; 597 | } 598 | return channels; 599 | } 600 | 601 | /** 602 | * Combines parallel audio data into correct format, 603 | * channels[Left[], Right[]] to float32Array[LRLRLRLR...] 604 | */ 605 | formatAudioData(channels) { 606 | if (channels.length === 1) { 607 | // Simple case is only one channel 608 | const float32Array = channels[0].slice(); 609 | const meanValues = channels[0].slice(); 610 | return { float32Array, meanValues }; 611 | } else { 612 | const float32Array = new Float32Array( 613 | channels[0].length * channels.length 614 | ); 615 | const meanValues = new Float32Array(channels[0].length); 616 | for (let i = 0; i < channels[0].length; i++) { 617 | const offset = i * channels.length; 618 | let meanValue = 0; 619 | for (let n = 0; n < channels.length; n++) { 620 | float32Array[offset + n] = channels[n][i]; 621 | meanValue += channels[n][i]; 622 | } 623 | meanValues[i] = meanValue / channels.length; 624 | } 625 | return { float32Array, meanValues }; 626 | } 627 | } 628 | 629 | /** 630 | * Converts 32-bit float data to 16-bit integers 631 | */ 632 | floatTo16BitPCM(float32Array) { 633 | const buffer = new ArrayBuffer(float32Array.length * 2); 634 | const view = new DataView(buffer); 635 | let offset = 0; 636 | for (let i = 0; i < float32Array.length; i++, offset += 2) { 637 | let s = Math.max(-1, Math.min(1, float32Array[i])); 638 | view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); 639 | } 640 | return buffer; 641 | } 642 | 643 | /** 644 | * Retrieves the most recent amplitude values from the audio stream 645 | * @param {number} channel 646 | */ 647 | getValues(channel = -1) { 648 | const channels = this.readChannelData(this.chunks, channel); 649 | const { meanValues } = this.formatAudioData(channels); 650 | return { meanValues, channels }; 651 | } 652 | 653 | /** 654 | * Exports chunks as an audio/wav file 655 | */ 656 | export() { 657 | const channels = this.readChannelData(this.chunks); 658 | const { float32Array, meanValues } = this.formatAudioData(channels); 659 | const audioData = this.floatTo16BitPCM(float32Array); 660 | return { 661 | meanValues: meanValues, 662 | audio: { 663 | bitsPerSample: 16, 664 | channels: channels, 665 | data: audioData, 666 | }, 667 | }; 668 | } 669 | 670 | receive(e) { 671 | const { event, id } = e.data; 672 | let receiptData = {}; 673 | switch (event) { 674 | case 'start': 675 | this.recording = true; 676 | break; 677 | case 'stop': 678 | this.recording = false; 679 | break; 680 | case 'clear': 681 | this.initialize(); 682 | break; 683 | case 'export': 684 | receiptData = this.export(); 685 | break; 686 | case 'read': 687 | receiptData = this.getValues(); 688 | break; 689 | default: 690 | break; 691 | } 692 | // Always send back receipt 693 | this.port.postMessage({ event: 'receipt', id, data: receiptData }); 694 | } 695 | 696 | sendChunk(chunk) { 697 | const channels = this.readChannelData([chunk]); 698 | const { float32Array, meanValues } = this.formatAudioData(channels); 699 | const rawAudioData = this.floatTo16BitPCM(float32Array); 700 | const monoAudioData = this.floatTo16BitPCM(meanValues); 701 | this.port.postMessage({ 702 | event: 'chunk', 703 | data: { 704 | mono: monoAudioData, 705 | raw: rawAudioData, 706 | }, 707 | }); 708 | } 709 | 710 | process(inputList, outputList, parameters) { 711 | // Copy input to output (e.g. speakers) 712 | // Note that this creates choppy sounds with Mac products 713 | const sourceLimit = Math.min(inputList.length, outputList.length); 714 | for (let inputNum = 0; inputNum < sourceLimit; inputNum++) { 715 | const input = inputList[inputNum]; 716 | const output = outputList[inputNum]; 717 | const channelCount = Math.min(input.length, output.length); 718 | for (let channelNum = 0; channelNum < channelCount; channelNum++) { 719 | input[channelNum].forEach((sample, i) => { 720 | output[channelNum][i] = sample; 721 | }); 722 | } 723 | } 724 | const inputs = inputList[0]; 725 | // There's latency at the beginning of a stream before recording starts 726 | // Make sure we actually receive audio data before we start storing chunks 727 | let sliceIndex = 0; 728 | if (!this.foundAudio) { 729 | for (const channel of inputs) { 730 | sliceIndex = 0; // reset for each channel 731 | if (this.foundAudio) { 732 | break; 733 | } 734 | if (channel) { 735 | for (const value of channel) { 736 | if (value !== 0) { 737 | // find only one non-zero entry in any channel 738 | this.foundAudio = true; 739 | break; 740 | } else { 741 | sliceIndex++; 742 | } 743 | } 744 | } 745 | } 746 | } 747 | if (inputs && inputs[0] && this.foundAudio && this.recording) { 748 | // We need to copy the TypedArray, because the \`process\` 749 | // internals will reuse the same buffer to hold each input 750 | const chunk = inputs.map((input) => input.slice(sliceIndex)); 751 | this.chunks.push(chunk); 752 | this.sendChunk(chunk); 753 | } 754 | return true; 755 | } 756 | } 757 | 758 | registerProcessor('audio_processor', AudioProcessor); 759 | `; 760 | var script2 = new Blob([AudioProcessorWorklet], { 761 | type: "application/javascript" 762 | }); 763 | var src2 = URL.createObjectURL(script2); 764 | var AudioProcessorSrc = src2; 765 | 766 | // lib/wav_recorder.js 767 | var WavRecorder = class { 768 | /** 769 | * Create a new WavRecorder instance 770 | * @param {{sampleRate?: number, outputToSpeakers?: boolean, debug?: boolean}} [options] 771 | * @returns {WavRecorder} 772 | */ 773 | constructor({ 774 | sampleRate = 44100, 775 | outputToSpeakers = false, 776 | debug = false 777 | } = {}) { 778 | this.scriptSrc = AudioProcessorSrc; 779 | this.sampleRate = sampleRate; 780 | this.outputToSpeakers = outputToSpeakers; 781 | this.debug = !!debug; 782 | this._deviceChangeCallback = null; 783 | this._devices = []; 784 | this.stream = null; 785 | this.processor = null; 786 | this.source = null; 787 | this.node = null; 788 | this.recording = false; 789 | this._lastEventId = 0; 790 | this.eventReceipts = {}; 791 | this.eventTimeout = 5e3; 792 | this._chunkProcessor = () => { 793 | }; 794 | this._chunkProcessorSize = void 0; 795 | this._chunkProcessorBuffer = { 796 | raw: new ArrayBuffer(0), 797 | mono: new ArrayBuffer(0) 798 | }; 799 | } 800 | /** 801 | * Decodes audio data from multiple formats to a Blob, url, Float32Array and AudioBuffer 802 | * @param {Blob|Float32Array|Int16Array|ArrayBuffer|number[]} audioData 803 | * @param {number} sampleRate 804 | * @param {number} fromSampleRate 805 | * @returns {Promise} 806 | */ 807 | static async decode(audioData, sampleRate = 44100, fromSampleRate = -1) { 808 | const context = new AudioContext({ sampleRate }); 809 | let arrayBuffer; 810 | let blob; 811 | if (audioData instanceof Blob) { 812 | if (fromSampleRate !== -1) { 813 | throw new Error( 814 | `Can not specify "fromSampleRate" when reading from Blob` 815 | ); 816 | } 817 | blob = audioData; 818 | arrayBuffer = await blob.arrayBuffer(); 819 | } else if (audioData instanceof ArrayBuffer) { 820 | if (fromSampleRate !== -1) { 821 | throw new Error( 822 | `Can not specify "fromSampleRate" when reading from ArrayBuffer` 823 | ); 824 | } 825 | arrayBuffer = audioData; 826 | blob = new Blob([arrayBuffer], { type: "audio/wav" }); 827 | } else { 828 | let float32Array; 829 | let data; 830 | if (audioData instanceof Int16Array) { 831 | data = audioData; 832 | float32Array = new Float32Array(audioData.length); 833 | for (let i = 0; i < audioData.length; i++) { 834 | float32Array[i] = audioData[i] / 32768; 835 | } 836 | } else if (audioData instanceof Float32Array) { 837 | float32Array = audioData; 838 | } else if (audioData instanceof Array) { 839 | float32Array = new Float32Array(audioData); 840 | } else { 841 | throw new Error( 842 | `"audioData" must be one of: Blob, Float32Arrray, Int16Array, ArrayBuffer, Array` 843 | ); 844 | } 845 | if (fromSampleRate === -1) { 846 | throw new Error( 847 | `Must specify "fromSampleRate" when reading from Float32Array, In16Array or Array` 848 | ); 849 | } else if (fromSampleRate < 3e3) { 850 | throw new Error(`Minimum "fromSampleRate" is 3000 (3kHz)`); 851 | } 852 | if (!data) { 853 | data = WavPacker.floatTo16BitPCM(float32Array); 854 | } 855 | const audio = { 856 | bitsPerSample: 16, 857 | channels: [float32Array], 858 | data 859 | }; 860 | const packer = new WavPacker(); 861 | const result = packer.pack(fromSampleRate, audio); 862 | blob = result.blob; 863 | arrayBuffer = await blob.arrayBuffer(); 864 | } 865 | const audioBuffer = await context.decodeAudioData(arrayBuffer); 866 | const values = audioBuffer.getChannelData(0); 867 | const url = URL.createObjectURL(blob); 868 | return { 869 | blob, 870 | url, 871 | values, 872 | audioBuffer 873 | }; 874 | } 875 | /** 876 | * Logs data in debug mode 877 | * @param {...any} arguments 878 | * @returns {true} 879 | */ 880 | log() { 881 | if (this.debug) { 882 | this.log(...arguments); 883 | } 884 | return true; 885 | } 886 | /** 887 | * Retrieves the current sampleRate for the recorder 888 | * @returns {number} 889 | */ 890 | getSampleRate() { 891 | return this.sampleRate; 892 | } 893 | /** 894 | * Retrieves the current status of the recording 895 | * @returns {"ended"|"paused"|"recording"} 896 | */ 897 | getStatus() { 898 | if (!this.processor) { 899 | return "ended"; 900 | } else if (!this.recording) { 901 | return "paused"; 902 | } else { 903 | return "recording"; 904 | } 905 | } 906 | /** 907 | * Sends an event to the AudioWorklet 908 | * @private 909 | * @param {string} name 910 | * @param {{[key: string]: any}} data 911 | * @param {AudioWorkletNode} [_processor] 912 | * @returns {Promise<{[key: string]: any}>} 913 | */ 914 | async _event(name, data = {}, _processor = null) { 915 | _processor = _processor || this.processor; 916 | if (!_processor) { 917 | throw new Error("Can not send events without recording first"); 918 | } 919 | const message = { 920 | event: name, 921 | id: this._lastEventId++, 922 | data 923 | }; 924 | _processor.port.postMessage(message); 925 | const t0 = (/* @__PURE__ */ new Date()).valueOf(); 926 | while (!this.eventReceipts[message.id]) { 927 | if ((/* @__PURE__ */ new Date()).valueOf() - t0 > this.eventTimeout) { 928 | throw new Error(`Timeout waiting for "${name}" event`); 929 | } 930 | await new Promise((res) => setTimeout(() => res(true), 1)); 931 | } 932 | const payload = this.eventReceipts[message.id]; 933 | delete this.eventReceipts[message.id]; 934 | return payload; 935 | } 936 | /** 937 | * Sets device change callback, remove if callback provided is `null` 938 | * @param {(Array): void|null} callback 939 | * @returns {true} 940 | */ 941 | listenForDeviceChange(callback) { 942 | if (callback === null && this._deviceChangeCallback) { 943 | navigator.mediaDevices.removeEventListener( 944 | "devicechange", 945 | this._deviceChangeCallback 946 | ); 947 | this._deviceChangeCallback = null; 948 | } else if (callback !== null) { 949 | let lastId = 0; 950 | let lastDevices = []; 951 | const serializeDevices = (devices) => devices.map((d) => d.deviceId).sort().join(","); 952 | const cb = async () => { 953 | let id = ++lastId; 954 | const devices = await this.listDevices(); 955 | if (id === lastId) { 956 | if (serializeDevices(lastDevices) !== serializeDevices(devices)) { 957 | lastDevices = devices; 958 | callback(devices.slice()); 959 | } 960 | } 961 | }; 962 | navigator.mediaDevices.addEventListener("devicechange", cb); 963 | cb(); 964 | this._deviceChangeCallback = cb; 965 | } 966 | return true; 967 | } 968 | /** 969 | * Manually request permission to use the microphone 970 | * @returns {Promise} 971 | */ 972 | async requestPermission() { 973 | const permissionStatus = await navigator.permissions.query({ 974 | name: "microphone" 975 | }); 976 | if (permissionStatus.state === "denied") { 977 | window.alert("You must grant microphone access to use this feature."); 978 | } else if (permissionStatus.state === "prompt") { 979 | try { 980 | const stream = await navigator.mediaDevices.getUserMedia({ 981 | audio: true 982 | }); 983 | const tracks = stream.getTracks(); 984 | tracks.forEach((track) => track.stop()); 985 | } catch (e) { 986 | window.alert("You must grant microphone access to use this feature."); 987 | } 988 | } 989 | return true; 990 | } 991 | /** 992 | * List all eligible devices for recording, will request permission to use microphone 993 | * @returns {Promise>} 994 | */ 995 | async listDevices() { 996 | if (!navigator.mediaDevices || !("enumerateDevices" in navigator.mediaDevices)) { 997 | throw new Error("Could not request user devices"); 998 | } 999 | await this.requestPermission(); 1000 | const devices = await navigator.mediaDevices.enumerateDevices(); 1001 | const audioDevices = devices.filter( 1002 | (device) => device.kind === "audioinput" 1003 | ); 1004 | const defaultDeviceIndex = audioDevices.findIndex( 1005 | (device) => device.deviceId === "default" 1006 | ); 1007 | const deviceList = []; 1008 | if (defaultDeviceIndex !== -1) { 1009 | let defaultDevice = audioDevices.splice(defaultDeviceIndex, 1)[0]; 1010 | let existingIndex = audioDevices.findIndex( 1011 | (device) => device.groupId === defaultDevice.groupId 1012 | ); 1013 | if (existingIndex !== -1) { 1014 | defaultDevice = audioDevices.splice(existingIndex, 1)[0]; 1015 | } 1016 | defaultDevice.default = true; 1017 | deviceList.push(defaultDevice); 1018 | } 1019 | return deviceList.concat(audioDevices); 1020 | } 1021 | /** 1022 | * Begins a recording session and requests microphone permissions if not already granted 1023 | * Microphone recording indicator will appear on browser tab but status will be "paused" 1024 | * @param {string} [deviceId] if no device provided, default device will be used 1025 | * @returns {Promise} 1026 | */ 1027 | async begin(deviceId) { 1028 | if (this.processor) { 1029 | throw new Error( 1030 | `Already connected: please call .end() to start a new session` 1031 | ); 1032 | } 1033 | if (!navigator.mediaDevices || !("getUserMedia" in navigator.mediaDevices)) { 1034 | throw new Error("Could not request user media"); 1035 | } 1036 | try { 1037 | const config = { audio: true }; 1038 | if (deviceId) { 1039 | config.audio = { deviceId: { exact: deviceId } }; 1040 | } 1041 | this.stream = await navigator.mediaDevices.getUserMedia(config); 1042 | } catch (err) { 1043 | throw new Error("Could not start media stream"); 1044 | } 1045 | const context = new AudioContext({ sampleRate: this.sampleRate }); 1046 | const source = context.createMediaStreamSource(this.stream); 1047 | try { 1048 | await context.audioWorklet.addModule(this.scriptSrc); 1049 | } catch (e) { 1050 | console.error(e); 1051 | throw new Error(`Could not add audioWorklet module: ${this.scriptSrc}`); 1052 | } 1053 | const processor = new AudioWorkletNode(context, "audio_processor"); 1054 | processor.port.onmessage = (e) => { 1055 | const { event, id, data } = e.data; 1056 | if (event === "receipt") { 1057 | this.eventReceipts[id] = data; 1058 | } else if (event === "chunk") { 1059 | if (this._chunkProcessorSize) { 1060 | const buffer = this._chunkProcessorBuffer; 1061 | this._chunkProcessorBuffer = { 1062 | raw: WavPacker.mergeBuffers(buffer.raw, data.raw), 1063 | mono: WavPacker.mergeBuffers(buffer.mono, data.mono) 1064 | }; 1065 | if (this._chunkProcessorBuffer.mono.byteLength >= this._chunkProcessorSize) { 1066 | this._chunkProcessor(this._chunkProcessorBuffer); 1067 | this._chunkProcessorBuffer = { 1068 | raw: new ArrayBuffer(0), 1069 | mono: new ArrayBuffer(0) 1070 | }; 1071 | } 1072 | } else { 1073 | this._chunkProcessor(data); 1074 | } 1075 | } 1076 | }; 1077 | const node = source.connect(processor); 1078 | const analyser = context.createAnalyser(); 1079 | analyser.fftSize = 8192; 1080 | analyser.smoothingTimeConstant = 0.1; 1081 | node.connect(analyser); 1082 | if (this.outputToSpeakers) { 1083 | console.warn( 1084 | "Warning: Output to speakers may affect sound quality,\nespecially due to system audio feedback preventative measures.\nuse only for debugging" 1085 | ); 1086 | analyser.connect(context.destination); 1087 | } 1088 | this.source = source; 1089 | this.node = node; 1090 | this.analyser = analyser; 1091 | this.processor = processor; 1092 | return true; 1093 | } 1094 | /** 1095 | * Gets the current frequency domain data from the recording track 1096 | * @param {"frequency"|"music"|"voice"} [analysisType] 1097 | * @param {number} [minDecibels] default -100 1098 | * @param {number} [maxDecibels] default -30 1099 | * @returns {import('./analysis/audio_analysis.js').AudioAnalysisOutputType} 1100 | */ 1101 | getFrequencies(analysisType = "frequency", minDecibels = -100, maxDecibels = -30) { 1102 | if (!this.processor) { 1103 | throw new Error("Session ended: please call .begin() first"); 1104 | } 1105 | return AudioAnalysis.getFrequencies( 1106 | this.analyser, 1107 | this.sampleRate, 1108 | null, 1109 | analysisType, 1110 | minDecibels, 1111 | maxDecibels 1112 | ); 1113 | } 1114 | /** 1115 | * Pauses the recording 1116 | * Keeps microphone stream open but halts storage of audio 1117 | * @returns {Promise} 1118 | */ 1119 | async pause() { 1120 | if (!this.processor) { 1121 | throw new Error("Session ended: please call .begin() first"); 1122 | } else if (!this.recording) { 1123 | throw new Error("Already paused: please call .record() first"); 1124 | } 1125 | if (this._chunkProcessorBuffer.raw.byteLength) { 1126 | this._chunkProcessor(this._chunkProcessorBuffer); 1127 | } 1128 | this.log("Pausing ..."); 1129 | await this._event("stop"); 1130 | this.recording = false; 1131 | return true; 1132 | } 1133 | /** 1134 | * Start recording stream and storing to memory from the connected audio source 1135 | * @param {(data: { mono: Int16Array; raw: Int16Array }) => any} [chunkProcessor] 1136 | * @param {number} [chunkSize] chunkProcessor will not be triggered until this size threshold met in mono audio 1137 | * @returns {Promise} 1138 | */ 1139 | async record(chunkProcessor = () => { 1140 | }, chunkSize = 8192) { 1141 | if (!this.processor) { 1142 | throw new Error("Session ended: please call .begin() first"); 1143 | } else if (this.recording) { 1144 | throw new Error("Already recording: please call .pause() first"); 1145 | } else if (typeof chunkProcessor !== "function") { 1146 | throw new Error(`chunkProcessor must be a function`); 1147 | } 1148 | this._chunkProcessor = chunkProcessor; 1149 | this._chunkProcessorSize = chunkSize; 1150 | this._chunkProcessorBuffer = { 1151 | raw: new ArrayBuffer(0), 1152 | mono: new ArrayBuffer(0) 1153 | }; 1154 | this.log("Recording ..."); 1155 | await this._event("start"); 1156 | this.recording = true; 1157 | return true; 1158 | } 1159 | /** 1160 | * Clears the audio buffer, empties stored recording 1161 | * @returns {Promise} 1162 | */ 1163 | async clear() { 1164 | if (!this.processor) { 1165 | throw new Error("Session ended: please call .begin() first"); 1166 | } 1167 | await this._event("clear"); 1168 | return true; 1169 | } 1170 | /** 1171 | * Reads the current audio stream data 1172 | * @returns {Promise<{meanValues: Float32Array, channels: Array}>} 1173 | */ 1174 | async read() { 1175 | if (!this.processor) { 1176 | throw new Error("Session ended: please call .begin() first"); 1177 | } 1178 | this.log("Reading ..."); 1179 | const result = await this._event("read"); 1180 | return result; 1181 | } 1182 | /** 1183 | * Saves the current audio stream to a file 1184 | * @param {boolean} [force] Force saving while still recording 1185 | * @returns {Promise} 1186 | */ 1187 | async save(force = false) { 1188 | if (!this.processor) { 1189 | throw new Error("Session ended: please call .begin() first"); 1190 | } 1191 | if (!force && this.recording) { 1192 | throw new Error( 1193 | "Currently recording: please call .pause() first, or call .save(true) to force" 1194 | ); 1195 | } 1196 | this.log("Exporting ..."); 1197 | const exportData = await this._event("export"); 1198 | const packer = new WavPacker(); 1199 | const result = packer.pack(this.sampleRate, exportData.audio); 1200 | return result; 1201 | } 1202 | /** 1203 | * Ends the current recording session and saves the result 1204 | * @returns {Promise} 1205 | */ 1206 | async end() { 1207 | if (!this.processor) { 1208 | throw new Error("Session ended: please call .begin() first"); 1209 | } 1210 | const _processor = this.processor; 1211 | this.log("Stopping ..."); 1212 | await this._event("stop"); 1213 | this.recording = false; 1214 | const tracks = this.stream.getTracks(); 1215 | tracks.forEach((track) => track.stop()); 1216 | this.log("Exporting ..."); 1217 | const exportData = await this._event("export", {}, _processor); 1218 | this.processor.disconnect(); 1219 | this.source.disconnect(); 1220 | this.node.disconnect(); 1221 | this.analyser.disconnect(); 1222 | this.stream = null; 1223 | this.processor = null; 1224 | this.source = null; 1225 | this.node = null; 1226 | const packer = new WavPacker(); 1227 | const result = packer.pack(this.sampleRate, exportData.audio); 1228 | return result; 1229 | } 1230 | /** 1231 | * Performs a full cleanup of WavRecorder instance 1232 | * Stops actively listening via microphone and removes existing listeners 1233 | * @returns {Promise} 1234 | */ 1235 | async quit() { 1236 | this.listenForDeviceChange(null); 1237 | if (this.processor) { 1238 | await this.end(); 1239 | } 1240 | return true; 1241 | } 1242 | }; 1243 | globalThis.WavRecorder = WavRecorder; 1244 | })(); 1245 | --------------------------------------------------------------------------------