-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #69 from wnfs-wg/audio
docs: Add audio example
- Loading branch information
Showing
8 changed files
with
637 additions
and
15 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
# demo | ||
|
||
## Run locally | ||
|
||
From this directory: | ||
|
||
```bash | ||
pnpm install | ||
pnpm dev | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
{ | ||
"name": "demo", | ||
"type": "module", | ||
"version": "1.0.0", | ||
"private": true, | ||
"description": "", | ||
"author": "Steven Vandevelde <[email protected]> (tokono.ma)", | ||
"license": "MIT", | ||
"keywords": [], | ||
"main": "src/main.jsx", | ||
"scripts": { | ||
"lint": "tsc --build && eslint . && prettier --check '**/*.{js,jsx,ts,tsx,yml,json,css}' --ignore-path ../../.gitignore", | ||
"dev": "rsbuild dev --open", | ||
"build": "rsbuild build", | ||
"preview": "rsbuild preview" | ||
}, | ||
"dependencies": { | ||
"@wnfs-wg/nest": "*", | ||
"blockstore-core": "^4.4.0", | ||
"blockstore-idb": "^1.1.8", | ||
"idb-keyval": "^6.2.1", | ||
"interface-blockstore": "^5.2.10", | ||
"mediainfo.js": "^0.2.1", | ||
"mime": "^4.0.1", | ||
"uint8arrays": "^5.0.2" | ||
}, | ||
"devDependencies": { | ||
"@rsbuild/core": "^0.4.4", | ||
"@types/node": "^20.11.0", | ||
"typescript": "5.4.2" | ||
}, | ||
"eslintConfig": { | ||
"extends": ["@fission-codes"], | ||
"ignorePatterns": ["dist"] | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import { defineConfig } from "@rsbuild/core"; | ||
|
||
export default defineConfig({ | ||
html: { | ||
template: "./src/index.html", | ||
}, | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
import type { Blockstore } from 'interface-blockstore' | ||
|
||
import { CID, FileSystem, Path } from '@wnfs-wg/nest' | ||
|
||
import * as IDB from 'idb-keyval' | ||
import * as Uint8Arr from 'uint8arrays' | ||
|
||
///////////////// | ||
// FILE SYSTEM // | ||
///////////////// | ||
|
||
export async function load({ | ||
blockstore, | ||
}: { | ||
blockstore: Blockstore | ||
}): Promise<FileSystem> { | ||
const dataRoot = await IDB.get('fs-pointer') | ||
const storedKey = await IDB.get('capsule-key') | ||
|
||
const fs = | ||
dataRoot === undefined | ||
? await FileSystem.create({ blockstore }) | ||
: await FileSystem.fromCID(CID.parse(dataRoot), { blockstore }) | ||
|
||
// Create new or load existing private directory at the root | ||
if (dataRoot && storedKey) { | ||
await fs.mountPrivateNode({ | ||
path: Path.root(), | ||
capsuleKey: Uint8Arr.fromString(storedKey, 'base64'), | ||
}) | ||
} else { | ||
const { capsuleKey } = await fs.mountPrivateNode({ | ||
path: Path.root(), | ||
}) | ||
|
||
IDB.set('capsule-key', Uint8Arr.toString(capsuleKey, 'base64')) | ||
savePointer(await fs.calculateDataRoot()) | ||
} | ||
|
||
// Fin | ||
return fs | ||
} | ||
|
||
export async function savePointer(dataRoot: CID): Promise<void> { | ||
await IDB.set('fs-pointer', dataRoot.toString()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
<!doctype html> | ||
<html lang="en"> | ||
<head> | ||
<meta charset="UTF-8" /> | ||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
<meta name="theme-color" content="#484A65" /> | ||
<meta name="description" content="Demo " /> | ||
<title>Demo</title> | ||
</head> | ||
<body> | ||
<input id="file-input" type="file" accept="audio/mp3" /> | ||
|
||
<div | ||
id="state" | ||
style=" | ||
font-family: ui-sans-serif, system-ui, sans-serif; | ||
margin: 1em 0 2em; | ||
" | ||
> | ||
Select an MP3 audio file to add to a WNFS and play it. | ||
</div> | ||
</body> | ||
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,180 @@ | ||
import { IDBBlockstore } from 'blockstore-idb' | ||
import { Path } from '@wnfs-wg/nest' | ||
import * as FS from './fs.ts' | ||
import mime from 'mime' | ||
|
||
// FS | ||
// -- | ||
|
||
const blockstore = new IDBBlockstore('blockstore') | ||
await blockstore.open() | ||
|
||
const fs = await FS.load({ blockstore }) | ||
|
||
// STATE NODE | ||
// ---------- | ||
|
||
const state = document.querySelector('#state') | ||
if (!state) throw new Error('Expected a #state element to exist') | ||
|
||
function note(msg: string) { | ||
if (state) state.innerHTML = msg | ||
} | ||
|
||
// FILE INPUT | ||
// ---------- | ||
|
||
const fi: HTMLInputElement | null = document.querySelector('#file-input') | ||
|
||
if (fi) | ||
fi.addEventListener('change', (event: Event) => { | ||
if (fi?.files?.length !== 1) return | ||
const file: File = fi.files[0] | ||
|
||
const reader = new FileReader() | ||
|
||
note('Reading file') | ||
|
||
reader.onload = (event: any) => { | ||
const data: ArrayBuffer = event.target.result | ||
init(file.name, data) | ||
} | ||
|
||
reader.readAsArrayBuffer(file) | ||
}) | ||
|
||
async function init(fileName: string, fileData: ArrayBuffer) { | ||
const mimeType = mime.getType(fileName) | ||
if (!mimeType || !mimeType.startsWith('audio/')) | ||
throw new Error('Not an audio file') | ||
|
||
console.log('Audio mimetype', mimeType) | ||
|
||
// File | ||
note('Adding file to WNFS') | ||
|
||
const path = Path.file('private', fileName) | ||
const { dataRoot } = await fs.write(path, 'bytes', new Uint8Array(fileData)) | ||
|
||
FS.savePointer(dataRoot) | ||
const fileSize = await fs.size(path) | ||
|
||
// Audio metadata | ||
note('Looking up audio metadata') | ||
|
||
const mediainfo = await ( | ||
await mediaInfoClient(true) | ||
).analyzeData( | ||
async (): Promise<number> => { | ||
return fileSize | ||
}, | ||
async (chunkSize: number, offset: number): Promise<Uint8Array> => { | ||
if (chunkSize === 0) return new Uint8Array() | ||
return fs.read(path, 'bytes', { offset, length: chunkSize }) | ||
} | ||
) | ||
|
||
// Audio duration | ||
const audioDuration = mediainfo?.media?.track[0]?.Duration | ||
if (!audioDuration) throw new Error('Failed to determine audio duration') | ||
|
||
console.log('Audio duration', audioDuration) | ||
console.log('Audio metadata', mediainfo.media.track) | ||
|
||
// Buffering | ||
const bufferSize = 512 * 1024 // 512 KB | ||
const metadataSize = mediainfo?.media?.track[0]?.StreamSize | ||
|
||
let start = 0 | ||
let end = 0 | ||
let sourceBuffer: SourceBuffer | ||
|
||
async function loadNext() { | ||
if (src.readyState === 'closed' || sourceBuffer.updating) return | ||
|
||
if (end >= fileSize) { | ||
note('Loaded all audio data') | ||
if (src.readyState === 'open') src.endOfStream() | ||
return | ||
} | ||
|
||
start = end | ||
end = | ||
start === 0 | ||
? metadataSize === undefined | ||
? bufferSize | ||
: metadataSize | ||
: start + bufferSize | ||
if (end >= fileSize) end = fileSize | ||
|
||
note(`Loading bytes, offset: ${start} - length: ${end - start}`) | ||
|
||
const buffer = await fs.read(path, 'bytes', { | ||
offset: start, | ||
length: end - start, | ||
}) | ||
|
||
sourceBuffer.appendBuffer(buffer) | ||
} | ||
|
||
// Media source | ||
note('Setting up media source') | ||
|
||
const src = new MediaSource() | ||
|
||
src.addEventListener('sourceopen', () => { | ||
if (src.sourceBuffers.length > 0) return | ||
console.log('src.readyState', src.readyState) | ||
|
||
if (src.readyState == 'open') { | ||
src.duration = audioDuration | ||
|
||
sourceBuffer = src.addSourceBuffer(mimeType) | ||
sourceBuffer.addEventListener('updateend', () => loadNext(), { | ||
once: true, | ||
}) | ||
|
||
note('Loading initial audio buffer') | ||
loadNext() | ||
} | ||
}) | ||
|
||
// Create audio | ||
const audio = new Audio() | ||
audio.src = URL.createObjectURL(src) | ||
audio.controls = true | ||
audio.volume = 0.5 | ||
// audio.preload = 'metadata' | ||
|
||
audio.addEventListener('seeking', () => { | ||
if (src.readyState === 'open') { | ||
// Abort current segment append. | ||
sourceBuffer.abort() | ||
} | ||
|
||
// TODO: | ||
// How do we determine what byte offset to load from based on the time. | ||
// start = n | ||
|
||
loadNext() | ||
}) | ||
|
||
audio.addEventListener('progress', () => loadNext()) | ||
|
||
document.body.appendChild(audio) | ||
} | ||
|
||
// AUDIO | ||
// ----- | ||
|
||
async function mediaInfoClient(covers: boolean) { | ||
const MediaInfoFactory = await import('mediainfo.js').then((a) => a.default) | ||
|
||
return await MediaInfoFactory({ | ||
coverData: covers, | ||
locateFile: () => { | ||
return new URL('mediainfo.js/MediaInfoModule.wasm', import.meta.url) | ||
.pathname | ||
}, | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
{ | ||
"compilerOptions": { | ||
"target": "ES2022", | ||
"lib": ["ESNext", "DOM", "DOM.Iterable", "WebWorker"], | ||
"moduleDetection": "force", | ||
"module": "ESNext", | ||
"moduleResolution": "Bundler", | ||
"types": ["vite/client"], | ||
"allowImportingTsExtensions": true, | ||
"resolveJsonModule": true, | ||
"outDir": "dist", | ||
"noEmit": true, | ||
"isolatedModules": true, | ||
"verbatimModuleSyntax": true, | ||
"esModuleInterop": true, | ||
// Advanced | ||
"forceConsistentCasingInFileNames": true, | ||
// Type Checking | ||
"strict": true, | ||
"noFallthroughCasesInSwitch": true, | ||
"noImplicitReturns": false, | ||
"noUnusedLocals": true, | ||
"noUnusedParameters": false, | ||
"skipLibCheck": true | ||
}, | ||
"exclude": ["node_modules", "dist", "out"] | ||
} |
Oops, something went wrong.