├── LICENSE ├── README.md ├── lib ├── Player.d.ts ├── Player.js ├── Player.js.map ├── index.d.ts ├── index.js ├── index.js.map └── tsconfig.tsbuildinfo ├── package.json ├── src ├── Player.ts └── index.ts └── tsconfig.json /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 李成 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # openai-speech-stream-player 2 | It's a player for play SSE streaming chunk from OpenAI audio speech API. 3 | 4 | 5 | ### Usage 6 | 7 | ```typescript 8 | import { SpeechPlayer } from 'openai-speech-stream-player'; 9 | 10 | async function main() { 11 | const audioEl = document.querySelector('audio'); 12 | const player = new SpeechPlayer({ 13 | audio: audioEl, 14 | onPlaying: () => {}, 15 | onPause: () => {}, 16 | onChunkEnd: () => {}, 17 | mimeType: 'audio/mpeg', 18 | }); 19 | await player.init(); 20 | 21 | var myHeaders = new Headers(); 22 | myHeaders.append("Cache-Control", "no-store"); 23 | myHeaders.append("Content-Type", "application/json"); 24 | myHeaders.append("Authorization", "Bearer yourKeyHere"); 25 | 26 | var raw = JSON.stringify({ 27 | "model": "tts-1", 28 | "input": "Hi, What's your name?", 29 | "voice": "shimmer", 30 | "response_format": "mp3", 31 | "speed": 1 32 | }); 33 | 34 | var requestOptions = { 35 | method: 'POST', 36 | headers: myHeaders, 37 | body: raw, 38 | redirect: 'follow' 39 | }; 40 | 41 | const response = await fetch("https://api.openai.com/v1/audio/speech", requestOptions); 42 | player.feedWithResponse(response); 43 | } 44 | 45 | main(); 46 | 47 | ``` 48 | 49 | Or you can DIY with response DIY 50 | 51 | ```typescript 52 | async function* streamAsyncIterable(stream) { 53 | const reader = stream.getReader(); 54 | 55 | try { 56 | while (true) { 57 | const { done, value } = await reader.read(); 58 | if (done) { 59 | return; 60 | } 61 | yield value; 62 | } 63 | } finally { 64 | reader.releaseLock(); 65 | } 66 | } 67 | 68 | async function main() { 69 | // ... 70 | const response = await fetch("https://api.openai.com/v1/audio/speech", requestOptions); 71 | 72 | for await (const chunk of streamAsyncIterable(response.body)) { 73 | player.feed(chunk); 74 | } 75 | } 76 | 77 | ``` 78 | 79 | ### Others 80 | 81 | 1. how to deal stream chunk in NodeJs form OpenAI text-to-speech API? 82 | 83 | here is my solution(code with nestJs): 84 | 85 | ```typescript 86 | import { Injectable, Logger } from '@nestjs/common'; 87 | import type { SpeechParams } from 'src/service/audio/Audio'; 88 | import type { Response } from 'express'; 89 | import { GPTAudio } from '../service/audio/Audio'; 90 | 91 | @Injectable() 92 | export class AudioService { 93 | private readonly logger = new Logger(AudioService.name); 94 | private readonly audio = new GPTAudio(); 95 | 96 | async speech(parmas: SpeechParams, res: Response): Promise { 97 | this.logger.log(`[AudioService speech]\n${JSON.stringify(parmas)}`); 98 | res.setHeader('Content-Type', 'audio/mpeg'); 99 | const response = await this.audio.speech(parmas); 100 | const body: NodeJS.ReadableStream = response.body as any; 101 | 102 | if (!body.on || !body.read) { 103 | throw new Error('unsupported "fetch" implementation'); 104 | } 105 | 106 | body.on('readable', () => { 107 | let chunk: string | Buffer; 108 | while (null !== (chunk = body.read())) { 109 | res.write(chunk); 110 | } 111 | }); 112 | body.on('close', () => { 113 | res.end(); 114 | }); 115 | } 116 | } 117 | ``` -------------------------------------------------------------------------------- /lib/Player.d.ts: -------------------------------------------------------------------------------- 1 | interface Options { 2 | onPlaying?: () => void; 3 | onPause?: () => void; 4 | onChunkEnd?: () => void; 5 | mimeType?: string; 6 | audio?: HTMLAudioElement; 7 | } 8 | /** 9 | * @typedef {{ 10 | * onPlaying?: () => void; 11 | * onPause?: () => void; 12 | * onChunkEnd?: () => void; 13 | * mimeType?: string; 14 | * audio?: HTMLAudioElement 15 | * }} Options 16 | */ 17 | declare class SpeechPlayer { 18 | /** @type { HTMLAudioElement } */ 19 | _audio: HTMLAudioElement; 20 | /** @type { MediaSource } */ 21 | mediaSource: MediaSource; 22 | /** @type { SourceBuffer } */ 23 | sourceBuffer: SourceBuffer; 24 | /** @type {() => void } */ 25 | initResolve: ((value: unknown) => void) | null; 26 | /** @type {ArrayBuffer[]} */ 27 | sourceBufferCache: ArrayBuffer[]; 28 | /** @type {boolean} */ 29 | destroyed: boolean; 30 | /** @type {boolean} */ 31 | mediaSourceOpened: boolean; 32 | /** @type {Options} */ 33 | options: Options; 34 | get audio(): HTMLAudioElement; 35 | set audio(audio: HTMLAudioElement); 36 | /** 37 | * @param { Options } options 38 | */ 39 | constructor(options?: Options); 40 | static streamAsyncIterable(stream: ReadableStream): AsyncGenerator; 41 | init(): Promise; 42 | sourceOpenHandle(): void; 43 | /** 44 | * Feed audio chunk data into player with SourceBuffer created from MediaSource 45 | * @param {Uint8Array} chunk 46 | */ 47 | feed(chunk: Uint8Array): void; 48 | /** 49 | * Feed audio chunk just with Fetch response and deal automaticlly. 50 | * @param {Response} response 51 | */ 52 | feedWithResponse(response: Response): Promise; 53 | play(): Promise; 54 | pause(): Promise; 55 | get paused(): boolean; 56 | get playing(): boolean; 57 | /** 58 | * Destroy speechPlayer instance, if want play again, need do init method again. 59 | */ 60 | destroy(): void; 61 | } 62 | export { SpeechPlayer }; 63 | -------------------------------------------------------------------------------- /lib/Player.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.SpeechPlayer = void 0; 4 | /** 5 | * @typedef {{ 6 | * onPlaying?: () => void; 7 | * onPause?: () => void; 8 | * onChunkEnd?: () => void; 9 | * mimeType?: string; 10 | * audio?: HTMLAudioElement 11 | * }} Options 12 | */ 13 | class SpeechPlayer { 14 | get audio() { 15 | return this._audio; 16 | } 17 | set audio(audio) { 18 | this._audio = audio; 19 | } 20 | /** 21 | * @param { Options } options 22 | */ 23 | constructor(options) { 24 | /** @type {ArrayBuffer[]} */ 25 | this.sourceBufferCache = []; 26 | /** @type {boolean} */ 27 | this.destroyed = false; 28 | /** @type {boolean} */ 29 | this.mediaSourceOpened = false; 30 | /** @type {Options} */ 31 | this.options = {}; 32 | if (options) { 33 | this.audio = options.audio || new Audio(); 34 | } 35 | this.options = options || {}; 36 | } 37 | static async *streamAsyncIterable(stream) { 38 | const reader = stream.getReader(); 39 | try { 40 | while (true) { 41 | const { done, value } = await reader.read(); 42 | if (done) { 43 | return; 44 | } 45 | yield value; 46 | } 47 | } 48 | finally { 49 | reader.releaseLock(); 50 | } 51 | } 52 | async init() { 53 | return new Promise((resolve, reject) => { 54 | this.destroyed = false; 55 | this.mediaSource = new MediaSource(); 56 | this.audio.src = URL.createObjectURL(this.mediaSource); 57 | this.initResolve = resolve; 58 | this.mediaSource.addEventListener('sourceopen', this.sourceOpenHandle.bind(this)); 59 | }); 60 | } 61 | sourceOpenHandle() { 62 | var _a, _b; 63 | if (this.initResolve) { 64 | this.initResolve(''); 65 | this.initResolve = null; 66 | URL.revokeObjectURL(this.audio.src); 67 | this.sourceBuffer = this.mediaSource.addSourceBuffer((_b = (_a = this.options) === null || _a === void 0 ? void 0 : _a.mimeType) !== null && _b !== void 0 ? _b : 'audio/mpeg'); 68 | let timer = 0; 69 | this.audio.addEventListener('playing', () => { 70 | this.options.onPlaying && this.options.onPlaying(); 71 | }); 72 | this.audio.addEventListener('pause', () => { 73 | this.options.onPause && this.options.onPause(); 74 | }); 75 | this.sourceBuffer.addEventListener('updateend', () => { 76 | timer && clearTimeout(timer); 77 | this.audio.paused && this.audio.play(); 78 | (!this.sourceBuffer.updating 79 | && this.sourceBufferCache.length) 80 | && this.sourceBuffer.appendBuffer(this.sourceBufferCache.shift()); 81 | }); 82 | this.audio.addEventListener('waiting', () => { 83 | timer = setTimeout(() => { 84 | if (!this.sourceBuffer.updating 85 | && this.mediaSource.readyState === 'open' 86 | && this.sourceBufferCache.length === 0) { 87 | this.mediaSource.endOfStream(); 88 | this.options.onChunkEnd && this.options.onChunkEnd(); 89 | } 90 | }, 500); 91 | }); 92 | this.mediaSourceOpened = true; 93 | } 94 | } 95 | /** 96 | * Feed audio chunk data into player with SourceBuffer created from MediaSource 97 | * @param {Uint8Array} chunk 98 | */ 99 | feed(chunk) { 100 | if (this.destroyed) 101 | throw new ReferenceError('SpeechPlayer has been destroyed.'); 102 | if (!this.mediaSourceOpened) 103 | throw new Error('MediaSource not opened, please do this update init resolved.'); 104 | this.sourceBufferCache.push(chunk); 105 | !this.sourceBuffer.updating && this.sourceBuffer.appendBuffer(this.sourceBufferCache.shift()); 106 | } 107 | /** 108 | * Feed audio chunk just with Fetch response and deal automaticlly. 109 | * @param {Response} response 110 | */ 111 | async feedWithResponse(response) { 112 | for await (const chunk of SpeechPlayer.streamAsyncIterable(response.body)) { 113 | this.feed(chunk); 114 | } 115 | } 116 | play() { 117 | return new Promise((resolve, reject) => { 118 | try { 119 | if (this.paused) { 120 | this.audio.play(); 121 | const playHandle = () => { 122 | resolve(true); 123 | this.audio.removeEventListener('playing', playHandle); 124 | }; 125 | this.audio.addEventListener('playing', playHandle); 126 | } 127 | else { 128 | // audio not exist or audio status is playing will resolve false result. 129 | resolve(false); 130 | } 131 | } 132 | catch (error) { 133 | reject(error); 134 | } 135 | }); 136 | } 137 | pause() { 138 | return new Promise((resolve, reject) => { 139 | try { 140 | if (this.playing) { 141 | this.audio.pause(); 142 | const pauseHandle = () => { 143 | this.audio.removeEventListener('pause', pauseHandle); 144 | resolve(true); 145 | }; 146 | this.audio.addEventListener('pause', pauseHandle); 147 | // puase event must be fired before setTimeout. 148 | setTimeout(() => { 149 | resolve(this.paused); 150 | }, 0); 151 | } 152 | else { 153 | // audio not exist or audio status is paused will resolve false result. 154 | resolve(false); 155 | } 156 | } 157 | catch (error) { 158 | reject(error); 159 | } 160 | }); 161 | } 162 | get paused() { 163 | return this.audio && this.audio.paused; 164 | } 165 | get playing() { 166 | return !this.paused; 167 | } 168 | /** 169 | * Destroy speechPlayer instance, if want play again, need do init method again. 170 | */ 171 | destroy() { 172 | if (this.audio && this.audio.paused === false) 173 | this.audio.pause(); 174 | this.destroyed = true; 175 | this.mediaSourceOpened = false; 176 | this.mediaSource && this.mediaSource.removeSourceBuffer(this.sourceBuffer); 177 | this.mediaSource && this.mediaSource.endOfStream(); 178 | this.sourceBuffer.abort(); 179 | this.sourceBufferCache.splice(0); 180 | } 181 | } 182 | exports.SpeechPlayer = SpeechPlayer; 183 | //# sourceMappingURL=Player.js.map -------------------------------------------------------------------------------- /lib/Player.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"Player.js","sourceRoot":"","sources":["../src/Player.ts"],"names":[],"mappings":"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"} -------------------------------------------------------------------------------- /lib/index.d.ts: -------------------------------------------------------------------------------- 1 | export { SpeechPlayer } from './Player'; 2 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.SpeechPlayer = void 0; 4 | var Player_1 = require("./Player"); 5 | Object.defineProperty(exports, "SpeechPlayer", { enumerable: true, get: function () { return Player_1.SpeechPlayer; } }); 6 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /lib/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAAA,mCAAwC;AAA/B,sGAAA,YAAY,OAAA"} -------------------------------------------------------------------------------- /lib/tsconfig.tsbuildinfo: -------------------------------------------------------------------------------- 1 | {"program":{"fileNames":["../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es5.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2015.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2016.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2017.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2018.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.dom.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.dom.iterable.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.webworker.importscripts.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.scripthost.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2015.core.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2015.collection.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2015.generator.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2015.promise.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2017.date.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2017.object.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2017.string.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2017.intl.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2018.intl.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2018.promise.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.decorators.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../../../../usr/local/cellar/typescript/5.2.2/libexec/lib/node_modules/typescript/lib/lib.es2018.full.d.ts","../src/player.ts","../src/index.ts"],"fileInfos":[{"version":"2ac9cdcfb8f8875c18d14ec5796a8b029c426f73ad6dc3ffb580c228b58d1c44","affectsGlobalScope":true},"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","dc48272d7c333ccf58034c0026162576b7d50ea0e69c3b9292f803fc20720fd5","9a68c0c07ae2fa71b44384a839b7b8d81662a236d4b9ac30916718f7510b1b2d","5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8",{"version":"0075fa5ceda385bcdf3488e37786b5a33be730e8bc4aa3cf1e78c63891752ce8","affectsGlobalScope":true},{"version":"35299ae4a62086698444a5aaee27fc7aa377c68cbb90b441c9ace246ffd05c97","affectsGlobalScope":true},{"version":"c5c5565225fce2ede835725a92a28ece149f83542aa4866cfb10290bff7b8996","affectsGlobalScope":true},{"version":"7d2dbc2a0250400af0809b0ad5f84686e84c73526de931f84560e483eb16b03c","affectsGlobalScope":true},{"version":"f296963760430fb65b4e5d91f0ed770a91c6e77455bacf8fa23a1501654ede0e","affectsGlobalScope":true},{"version":"09226e53d1cfda217317074a97724da3e71e2c545e18774484b61562afc53cd2","affectsGlobalScope":true},{"version":"4443e68b35f3332f753eacc66a04ac1d2053b8b035a0e0ac1d455392b5e243b3","affectsGlobalScope":true},{"version":"8b41361862022eb72fcc8a7f34680ac842aca802cf4bc1f915e8c620c9ce4331","affectsGlobalScope":true},{"version":"f7bd636ae3a4623c503359ada74510c4005df5b36de7f23e1db8a5c543fd176b","affectsGlobalScope":true},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true},{"version":"0c20f4d2358eb679e4ae8a4432bdd96c857a2960fd6800b21ec4008ec59d60ea","affectsGlobalScope":true},{"version":"93495ff27b8746f55d19fcbcdbaccc99fd95f19d057aed1bd2c0cafe1335fbf0","affectsGlobalScope":true},{"version":"82d0d8e269b9eeac02c3bd1c9e884e85d483fcb2cd168bccd6bc54df663da031","affectsGlobalScope":true},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true},{"version":"b8deab98702588840be73d67f02412a2d45a417a3c097b2e96f7f3a42ac483d1","affectsGlobalScope":true},{"version":"4738f2420687fd85629c9efb470793bb753709c2379e5f85bc1815d875ceadcd","affectsGlobalScope":true},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true},{"version":"376d554d042fb409cb55b5cbaf0b2b4b7e669619493c5d18d5fa8bd67273f82a","affectsGlobalScope":true},{"version":"9fc46429fbe091ac5ad2608c657201eb68b6f1b8341bd6d670047d32ed0a88fa","affectsGlobalScope":true},{"version":"61c37c1de663cf4171e1192466e52c7a382afa58da01b1dc75058f032ddf0839","affectsGlobalScope":true},{"version":"c4138a3dd7cd6cf1f363ca0f905554e8d81b45844feea17786cdf1626cb8ea06","affectsGlobalScope":true},{"version":"6ff3e2452b055d8f0ec026511c6582b55d935675af67cdb67dd1dc671e8065df","affectsGlobalScope":true},{"version":"03de17b810f426a2f47396b0b99b53a82c1b60e9cba7a7edda47f9bb077882f4","affectsGlobalScope":true},{"version":"8184c6ddf48f0c98429326b428478ecc6143c27f79b79e85740f17e6feb090f1","affectsGlobalScope":true},{"version":"f35a831e4f0fe3b3697f4a0fe0e3caa7624c92b78afbecaf142c0f93abfaf379","affectsGlobalScope":true},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true},"f221625bd128d33cd4b4572b6a73a6890a604a92e5ce8e691ef0a6be09452110",{"version":"e8a7fc6782f73a87737d695c519180c9b445a5bdf3f29664f73b83a68467f159","signature":"aa775e4d3379df97268e61815a7fac1226faf05b1229e8e1fb61c1aaa958e849"},{"version":"ca930b496cca4f4396af07edb8add414a32645157eb1a85086f98025f09dd58a","signature":"d7206e689dbc0b7a1b3669b3a4249c63d55de20c4c81ee92edb97812b987dd85"}],"root":[34,35],"options":{"allowSyntheticDefaultImports":true,"declaration":true,"emitDecoratorMetadata":true,"esModuleInterop":true,"experimentalDecorators":true,"module":1,"noFallthroughCasesInSwitch":false,"noImplicitAny":false,"outDir":"./","skipLibCheck":true,"sourceMap":true,"strictBindCallApply":false,"strictNullChecks":false,"target":5},"fileIdsList":[[34]],"referencedMap":[[35,1]],"exportedModulesMap":[[35,1]],"semanticDiagnosticsPerFile":[35,34,31,32,6,7,11,10,2,12,13,14,15,16,17,18,19,3,4,20,24,21,22,23,25,26,27,5,33,28,29,30,1,9,8]},"version":"5.2.2"} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openai-speech-stream-player", 3 | "version": "1.0.8", 4 | "description": "It's a player for play SSE streaming chunk from OpenAI audio speech API.", 5 | "main": "lib/index.js", 6 | "homepage": "https://github.com/kvsur/openai-speech-stream-player", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": ["OpenAI Audio", "Speech API", "stream", "player", "SSE", "stream chunk player"], 11 | "author": "", 12 | "license": "ISC" 13 | } 14 | -------------------------------------------------------------------------------- /src/Player.ts: -------------------------------------------------------------------------------- 1 | interface Options { 2 | onPlaying?: () => void; 3 | onPause?: () => void; 4 | onChunkEnd?: () => void; 5 | mimeType?: string; 6 | audio?: HTMLAudioElement; 7 | } 8 | 9 | /** 10 | * @typedef {{ 11 | * onPlaying?: () => void; 12 | * onPause?: () => void; 13 | * onChunkEnd?: () => void; 14 | * mimeType?: string; 15 | * audio?: HTMLAudioElement 16 | * }} Options 17 | */ 18 | class SpeechPlayer { 19 | /** @type { HTMLAudioElement } */ 20 | _audio: HTMLAudioElement; 21 | /** @type { MediaSource } */ 22 | mediaSource: MediaSource; 23 | /** @type { SourceBuffer } */ 24 | sourceBuffer: SourceBuffer; 25 | /** @type {() => void } */ 26 | initResolve: ((value: unknown) => void) | null; 27 | /** @type {ArrayBuffer[]} */ 28 | sourceBufferCache: ArrayBuffer[] = []; 29 | /** @type {boolean} */ 30 | destroyed: boolean = false; 31 | /** @type {boolean} */ 32 | mediaSourceOpened: boolean = false; 33 | /** @type {Options} */ 34 | options: Options = {}; 35 | 36 | get audio() { 37 | return this._audio; 38 | } 39 | 40 | set audio(audio) { 41 | this._audio = audio; 42 | } 43 | 44 | /** 45 | * @param { Options } options 46 | */ 47 | constructor(options?: Options) { 48 | if (options) { 49 | this.audio = options.audio || new Audio(); 50 | } 51 | this.options = options || {}; 52 | } 53 | 54 | static async *streamAsyncIterable(stream: ReadableStream) { 55 | const reader = stream.getReader(); 56 | 57 | try { 58 | while (true) { 59 | const { done, value } = await reader.read(); 60 | if (done) { 61 | return; 62 | } 63 | yield value; 64 | } 65 | } finally { 66 | reader.releaseLock(); 67 | } 68 | } 69 | 70 | async init() { 71 | return new Promise((resolve, reject) => { 72 | this.destroyed = false; 73 | this.mediaSource = new MediaSource(); 74 | this.audio.src = URL.createObjectURL(this.mediaSource); 75 | this.initResolve = resolve; 76 | this.mediaSource.addEventListener('sourceopen', this.sourceOpenHandle.bind(this)); 77 | }); 78 | } 79 | 80 | sourceOpenHandle() { 81 | if (this.initResolve) { 82 | this.initResolve(''); 83 | this.initResolve = null; 84 | URL.revokeObjectURL(this.audio.src); 85 | 86 | this.sourceBuffer = this.mediaSource.addSourceBuffer(this.options?.mimeType ?? 'audio/mpeg'); 87 | let timer = 0; 88 | this.audio.addEventListener('playing', () => { 89 | this.options.onPlaying && this.options.onPlaying(); 90 | }); 91 | this.audio.addEventListener('pause', () => { 92 | this.options.onPause && this.options.onPause(); 93 | }); 94 | this.sourceBuffer.addEventListener('updateend', () => { 95 | timer && clearTimeout(timer); 96 | this.audio.paused && this.audio.play(); 97 | (!this.sourceBuffer.updating 98 | && this.sourceBufferCache.length) 99 | && this.sourceBuffer.appendBuffer(this.sourceBufferCache.shift() as ArrayBuffer); 100 | }); 101 | this.audio.addEventListener('waiting', () => { 102 | timer = setTimeout(() => { 103 | if (!this.sourceBuffer.updating 104 | && this.mediaSource.readyState === 'open' 105 | && this.sourceBufferCache.length === 0) { 106 | this.mediaSource.endOfStream(); 107 | this.options.onChunkEnd && this.options.onChunkEnd(); 108 | } 109 | }, 500); 110 | }); 111 | this.mediaSourceOpened = true; 112 | } 113 | } 114 | 115 | /** 116 | * Feed audio chunk data into player with SourceBuffer created from MediaSource 117 | * @param {Uint8Array} chunk 118 | */ 119 | feed(chunk: Uint8Array) { 120 | if (this.destroyed) throw new ReferenceError('SpeechPlayer has been destroyed.'); 121 | if (!this.mediaSourceOpened) throw new Error('MediaSource not opened, please do this update init resolved.'); 122 | this.sourceBufferCache.push(chunk); 123 | !this.sourceBuffer.updating && this.sourceBuffer.appendBuffer(this.sourceBufferCache.shift() as ArrayBuffer); 124 | } 125 | 126 | /** 127 | * Feed audio chunk just with Fetch response and deal automaticlly. 128 | * @param {Response} response 129 | */ 130 | async feedWithResponse(response: Response) { 131 | for await (const chunk of SpeechPlayer.streamAsyncIterable(response.body as ReadableStream)) { 132 | this.feed(chunk); 133 | } 134 | } 135 | 136 | play(): Promise { 137 | return new Promise((resolve, reject) => { 138 | try { 139 | if (this.paused) { 140 | this.audio.play(); 141 | const playHandle = () => { 142 | resolve(true); 143 | this.audio.removeEventListener('playing', playHandle); 144 | }; 145 | this.audio.addEventListener('playing', playHandle); 146 | } else { 147 | // audio not exist or audio status is playing will resolve false result. 148 | resolve(false); 149 | } 150 | } catch (error) { 151 | reject(error); 152 | } 153 | }); 154 | } 155 | 156 | pause(): Promise { 157 | return new Promise((resolve, reject) => { 158 | try { 159 | if (this.playing) { 160 | this.audio.pause(); 161 | const pauseHandle = () => { 162 | this.audio.removeEventListener('pause', pauseHandle); 163 | resolve(true); 164 | }; 165 | this.audio.addEventListener('pause', pauseHandle); 166 | // puase event must be fired before setTimeout. 167 | setTimeout(() => { 168 | resolve(this.paused); 169 | }, 0); 170 | } else { 171 | // audio not exist or audio status is paused will resolve false result. 172 | resolve(false); 173 | } 174 | } catch (error) { 175 | reject(error); 176 | } 177 | }); 178 | } 179 | 180 | get paused() { 181 | return this.audio && this.audio.paused; 182 | } 183 | 184 | get playing() { 185 | return !this.paused; 186 | } 187 | 188 | /** 189 | * Destroy speechPlayer instance, if want play again, need do init method again. 190 | */ 191 | destroy() { 192 | if (this.audio && this.audio.paused === false) this.audio.pause(); 193 | this.destroyed = true; 194 | this.mediaSourceOpened = false; 195 | this.mediaSource && this.mediaSource.removeSourceBuffer(this.sourceBuffer as SourceBuffer); 196 | this.mediaSource && this.mediaSource.endOfStream(); 197 | this.sourceBuffer.abort(); 198 | this.sourceBufferCache.splice(0); 199 | } 200 | } 201 | 202 | export { SpeechPlayer }; 203 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { SpeechPlayer } from './Player'; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "module": "commonjs", 5 | "declaration": true, 6 | // "removeComments": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "allowSyntheticDefaultImports": true, 10 | "target": "ES2018", 11 | "sourceMap": true, 12 | "outDir": "./lib", 13 | "baseUrl": "./", 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "strictNullChecks": false, 17 | "noImplicitAny": false, 18 | "strictBindCallApply": false, 19 | "forceConsistentCasingInFileNames": false, 20 | "noFallthroughCasesInSwitch": false, 21 | "esModuleInterop": true, 22 | "resolveJsonModule": true, 23 | } 24 | } 25 | --------------------------------------------------------------------------------