Skip to content

Commit

Permalink
Fix handling reading AMR audio file from stream
Browse files Browse the repository at this point in the history
Properly handle `EndOfStreamError`, in case the length of the stream is unknown (not provided).
  • Loading branch information
Borewit committed Jan 4, 2025
1 parent 91408ba commit 409e9dd
Show file tree
Hide file tree
Showing 6 changed files with 92 additions and 11 deletions.
13 changes: 10 additions & 3 deletions lib/amr/AmrParser.ts
Original file line number Diff line number Diff line change
@@ -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');

Expand Down Expand Up @@ -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];
Expand All @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion lib/amr/AmrToken.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { IGetToken } from 'strtok3';
import { getBitAllignedNumber } from '../common/Util.js';

interface IFrameHeader {
export interface IFrameHeader {
frameType: number;
}

Expand Down
9 changes: 7 additions & 2 deletions lib/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,13 @@ export async function parseBlob(blob: Blob, options: IOptions = {}): Promise<IAu
* @param fileInfo - File information object or MIME-type string
* @returns Metadata
*/
export function parseWebStream(webStream: AnyWebByteStream, fileInfo?: IFileInfo | string, options: IOptions = {}): Promise<IAudioMetadata> {
return parseFromTokenizer(fromWebStream(webStream, {fileInfo: typeof fileInfo === 'string' ? {mimeType: fileInfo} : fileInfo}), options);
export async function parseWebStream(webStream: AnyWebByteStream, fileInfo?: IFileInfo | string, options: IOptions = {}): Promise<IAudioMetadata> {
const tokenizer = fromWebStream(webStream, {fileInfo: typeof fileInfo === 'string' ? {mimeType: fileInfo} : fileInfo});
try {
return await parseFromTokenizer(tokenizer, options);
} finally {
await tokenizer.close();
}
}

/**
Expand Down
22 changes: 19 additions & 3 deletions test/metadata-parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}>;

Expand All @@ -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();
}
}
}, {
Expand Down
7 changes: 5 additions & 2 deletions test/test-file-amr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down
50 changes: 50 additions & 0 deletions test/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -22,4 +24,52 @@ export class SourceStream extends Readable {
}
}

export async function makeReadableByteFileStream(filename: string, delay = 0): Promise<{ fileSize: number, stream: ReadableStream<Uint8Array>, closeFile: () => Promise<void> }> {

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');

0 comments on commit 409e9dd

Please sign in to comment.