diff --git a/lib/amr/AmrParser.ts b/lib/amr/AmrParser.ts index 9c0925ad0..10237c4ce 100644 --- a/lib/amr/AmrParser.ts +++ b/lib/amr/AmrParser.ts @@ -1,7 +1,8 @@ import { BasicParser } from '../common/BasicParser.js'; import { AnsiStringType } from 'token-types'; import initDebug from 'debug'; -import { FrameHeader } from './AmrToken.js'; +import { FrameHeader, type IFrameHeader } from './AmrToken.js'; +import { EndOfStreamError } from 'strtok3'; const debug = initDebug('music-metadata:parser:AMR'); @@ -34,9 +35,16 @@ export class AmrParser extends BasicParser { const assumedFileLength = this.tokenizer.fileInfo?.size ?? Number.MAX_SAFE_INTEGER; if (this.options.duration) { + let header: IFrameHeader; while (this.tokenizer.position < assumedFileLength) { - const header = await this.tokenizer.readToken(FrameHeader); + try { + header = await this.tokenizer.readToken(FrameHeader); + } catch(error) { + if (error instanceof EndOfStreamError) + break; + throw error; + } /* first byte is rate mode. each rate mode has frame of given length. look it up. */ const size = m_block_size[header.frameType]; @@ -49,7 +57,6 @@ export class AmrParser extends BasicParser { } else { debug(`Found no-data frame, frame-type: ${header.frameType}. Skipping`); } - } this.metadata.setFormat('duration', frames * 0.02); } diff --git a/lib/amr/AmrToken.ts b/lib/amr/AmrToken.ts index b5039a126..e3700e59a 100644 --- a/lib/amr/AmrToken.ts +++ b/lib/amr/AmrToken.ts @@ -1,7 +1,7 @@ import type { IGetToken } from 'strtok3'; import { getBitAllignedNumber } from '../common/Util.js'; -interface IFrameHeader { +export interface IFrameHeader { frameType: number; } diff --git a/lib/core.ts b/lib/core.ts index 91e787530..b2441108e 100644 --- a/lib/core.ts +++ b/lib/core.ts @@ -39,8 +39,13 @@ export async function parseBlob(blob: Blob, options: IOptions = {}): Promise { - return parseFromTokenizer(fromWebStream(webStream, {fileInfo: typeof fileInfo === 'string' ? {mimeType: fileInfo} : fileInfo}), options); +export async function parseWebStream(webStream: AnyWebByteStream, fileInfo?: IFileInfo | string, options: IOptions = {}): Promise { + const tokenizer = fromWebStream(webStream, {fileInfo: typeof fileInfo === 'string' ? {mimeType: fileInfo} : fileInfo}); + try { + return await parseFromTokenizer(tokenizer, options); + } finally { + await tokenizer.close(); + } } /** diff --git a/test/metadata-parsers.ts b/test/metadata-parsers.ts index da7c1a5f9..cc09889e3 100644 --- a/test/metadata-parsers.ts +++ b/test/metadata-parsers.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import * as mm from '../lib/index.js'; import type { IAudioMetadata, IOptions } from '../lib/index.js'; +import { makeReadableByteFileStream } from './util.js'; type ParseFileMethod = (skipTest: () => void, filePath: string, mimeType?: string, options?: IOptions) => Promise<{metadata: IAudioMetadata, randomRead: boolean}>; @@ -27,14 +28,29 @@ export const Parsers: IParser[] = [ }, { description: 'parseStream (Node.js)', initParser: async (skipTest, filePath: string, mimeType?: string, options?: IOptions) => { - const stream = fs.createReadStream(filePath); + const nodeStream = fs.createReadStream(filePath); try { return { - metadata: await mm.parseStream(stream, {mimeType: mimeType}, options), + metadata: await mm.parseStream(nodeStream, {mimeType: mimeType}, options), randomRead: false }; } finally { - stream.close(); + nodeStream.close(); + } + } + }, { + description: 'parseWebStream', + initParser: async (skipTest, filePath: string, mimeType?: string, options?: IOptions) => { + const webStream = await makeReadableByteFileStream(filePath); + try { + return { + // ToDo: add unit tests, where the fileSize is not provided (passed) + metadata: await mm.parseWebStream(webStream.stream, {mimeType: mimeType, size: webStream.fileSize}, options), + randomRead: false + }; + } finally { + await webStream.stream.cancel() + await webStream.closeFile(); } } }, { diff --git a/test/test-file-amr.ts b/test/test-file-amr.ts index c0e837a9a..8d23ca77b 100644 --- a/test/test-file-amr.ts +++ b/test/test-file-amr.ts @@ -7,9 +7,12 @@ const amrPath = path.join(samplePath, 'amr'); describe('Adaptive Multi-Rate (AMR) audio file', () => { - Parsers.forEach(parser => { + Parsers + .filter(parser => parser.description !== 'parseWebStream') // Parsing if AMR to slow via web-stream + .forEach(parser => { - describe('parser.description', () => { + describe(parser.description, function () { + this.timeout(20000); it('parse: sample.amr', async function () { const {metadata} = await parser.initParser(() => this.skip(), path.join(amrPath, 'sample.amr'), 'audio/amr', {duration: true}); diff --git a/test/util.ts b/test/util.ts index c4c9b5b1f..9d16c3248 100644 --- a/test/util.ts +++ b/test/util.ts @@ -3,6 +3,8 @@ import { Readable } from 'node:stream'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import fs from 'node:fs/promises'; +import { ReadableStream } from 'node:stream/web'; const filename = fileURLToPath(import.meta.url); const dirname = path.dirname(filename); @@ -22,4 +24,52 @@ export class SourceStream extends Readable { } } +export async function makeReadableByteFileStream(filename: string, delay = 0): Promise<{ fileSize: number, stream: ReadableStream, closeFile: () => Promise }> { + + let position = 0; + const fileInfo = await fs.stat(filename); + const fileHandle = await fs.open(filename, 'r'); + + return { + fileSize: fileInfo.size, + stream: new ReadableStream({ + type: 'bytes', + + async pull(controller) { + + // @ts-ignore + const view = controller.byobRequest.view; + + setTimeout(async () => { + try { + const {bytesRead} = await fileHandle.read(view, 0, view.byteLength, position); + if (bytesRead === 0) { + await fileHandle.close(); + controller.close(); + // @ts-ignore + controller.byobRequest.respond(0); + } else { + position += bytesRead; + // @ts-ignore + controller.byobRequest.respond(bytesRead); + } + } catch (err) { + controller.error(err); + await fileHandle.close(); + } + }, delay); + }, + + cancel() { + return fileHandle.close(); + }, + + autoAllocateChunkSize: 1024 + }), + closeFile: () => { + return fileHandle.close(); + } + }; +} + export const samplePath = path.join(dirname, 'samples');