Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/codspeed.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CodSpeed

on:
push:
branches:
- master
pull_request:
# Allow running this workflow manually from the Actions tab
workflow_dispatch:

permissions:
contents: read
id-token: write

jobs:
benchmarks:
name: Run benchmarks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Run benchmarks
uses: CodSpeedHQ/action@v4
with:
mode: simulation
run: bunx vitest bench --run
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# downcloud

[![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://app.codspeed.io/yaaaarn/downcloud?utm_source=badge)

a simple (and fast) soundcloud downloader.

## install
Expand Down
84 changes: 84 additions & 0 deletions bench/lib.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { bench, describe } from "vitest";
import {
extractAssetUrls,
findClientIdInChunk,
compressWaveform,
normalizeWaveform,
renderAsciiWaveform,
originalArtworkUrl,
formatTime,
} from "../lib";

// --- Synthetic but representative inputs -------------------------------------

// A SoundCloud homepage references a handful of bundled JS assets among a large
// amount of surrounding markup. Build a realistically sized HTML document.
const homepageHtml = (() => {
const filler = "<div class=\"l-container\">lorem ipsum dolor sit amet</div>".repeat(2000);
const assets = Array.from(
{ length: 12 },
(_, i) =>
`<script crossorigin src="https://a-v2.sndcdn.com/assets/${i}-deadbeef${i}cafe.js"></script>`,
).join("\n");
return `<!doctype html><html><head>${filler}</head><body>${assets}${filler}</body></html>`;
})();

// The asset chunk that actually contains the client_id is a large minified
// bundle; the token sits well into the file.
const assetChunkWithClientId = (() => {
const head = "n.exports=function(e){return e};".repeat(8000);
return `${head}var o={client_id:"a1B2c3D4e5F6g7H8i9J0kLmNoPqR",app_version:"172"};${head}`;
})();

// SoundCloud waveform payloads are arrays of amplitude samples. Real waveforms
// contain ~1800 samples; generate a deterministic, varied profile.
const waveformSamples = Array.from({ length: 1800 }, (_, i) =>
Math.round((Math.sin(i / 17) * 0.5 + 0.5) * 100 + (i % 7) * 3),
);

const largeWaveformSamples = Array.from({ length: 20000 }, (_, i) =>
Math.round((Math.sin(i / 23) * 0.5 + 0.5) * 100 + (i % 11) * 2),
);

const artworkUrl =
"https://i1.sndcdn.com/artworks-000123456789-abcdef-large.jpg";

// --- Benchmarks --------------------------------------------------------------

describe("client_id discovery", () => {
bench("extractAssetUrls (homepage HTML)", () => {
extractAssetUrls(homepageHtml);
});

bench("findClientIdInChunk (minified asset bundle)", () => {
findClientIdInChunk(assetChunkWithClientId);
});
});

describe("waveform processing", () => {
bench("compressWaveform (1800 samples)", () => {
compressWaveform(waveformSamples, 75);
});

bench("normalizeWaveform (1800 samples)", () => {
normalizeWaveform(waveformSamples, 75);
});

bench("renderAsciiWaveform (1800 samples)", () => {
renderAsciiWaveform(waveformSamples, 75);
});

bench("renderAsciiWaveform (20000 samples)", () => {
renderAsciiWaveform(largeWaveformSamples, 75);
});
});

describe("metadata helpers", () => {
bench("originalArtworkUrl", () => {
originalArtworkUrl(artworkUrl);
});

bench("formatTime", () => {
formatTime(213_456);
});
});
226 changes: 226 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

448 changes: 448 additions & 0 deletions bun.nix

Large diffs are not rendered by default.

67 changes: 17 additions & 50 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import { mkdir, unlink } from "node:fs/promises";
import { secrets } from "bun";
import { Command } from "commander";
import chalk from "chalk";
import {
extractAssetUrls,
findClientIdInChunk,
normalizeWaveform,
renderAsciiWaveform,
originalArtworkUrl,
formatTime,
} from "./lib";

const userAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.114 Safari/537.36";
const CACHE_FILE = join(process.env.HOME || ".", ".downcloud_client_id");
Expand Down Expand Up @@ -53,9 +61,8 @@ async function resolveClientId(): Promise<string> {
headers: { "User-Agent": userAgent },
}).then(r => r.text());

const assetRegex = /src="(https:\/\/a-v2\.sndcdn\.com\/assets\/[^"]+\.js)"/g;
const assetUrls = Array.from(html.matchAll(assetRegex), m => m[1]);
clientId = await findClientId(assetUrls.filter(x => x != null));
const assetUrls = extractAssetUrls(html);
clientId = await findClientId(assetUrls);
if (!clientId) throw new Error("could not find client_id");
await cacheFile.write(clientId);
return clientId;
Expand Down Expand Up @@ -104,9 +111,9 @@ async function findClientIdInAsset(url: string, signal: AbortSignal) {

chunk += decoder.decode(value, { stream: true });

const match = chunk.match(/client_id:"([a-zA-Z0-9]+)"/);
if (match) {
return match[1];
const clientId = findClientIdInChunk(chunk);
if (clientId) {
return clientId;
}

if (chunk.length > 10000) {
Expand Down Expand Up @@ -140,36 +147,7 @@ async function printAsciiWaveform(waveformUrl: string) {
const { samples } = await res.json() as { samples: number[] };
if (!samples || samples.length === 0) return;

const targetWidth = 75;
const maxVal = Math.max(...samples) || 1;

const compressed: number[] = [];
const chunkSize = Math.ceil(samples.length / targetWidth);

for (let i = 0; i < samples.length; i += chunkSize) {
const chunk = samples.slice(i, i + chunkSize);
const avg = chunk.reduce((sum, v) => sum + v, 0) / chunk.length;
compressed.push(avg);
}

const topSet = [" ", "▖", "▌"];
const botSet = [" ", "▘", "▌"];

const topRow = compressed
.map(v => {
const norm = v / maxVal;
const index = Math.min(Math.floor(norm * topSet.length), topSet.length - 1);
return topSet[index];
})
.join("");

const bottomRow = compressed
.map(v => {
const norm = v / maxVal;
const index = Math.min(Math.floor(norm * botSet.length), botSet.length - 1);
return botSet[index];
})
.join("");
const { top: topRow, bottom: bottomRow } = renderAsciiWaveform(samples, 75);

console.log()
console.log(topRow);
Expand Down Expand Up @@ -262,15 +240,7 @@ async function saveAudio(streamUrl: string, isDownload: boolean, customOutFile:
if (res.ok) {
const { samples: raw } = await res.json() as { samples: number[] };
if (raw?.length) {
const targetWidth = 75;
const chunkSize = Math.ceil(raw.length / targetWidth);
const compressed: number[] = [];
for (let i = 0; i < raw.length; i += chunkSize) {
const chunk = raw.slice(i, i + chunkSize);
compressed.push(chunk.reduce((s, v) => s + v, 0) / chunk.length);
}
const maxVal = Math.max(...compressed) || 1;
samples = compressed.map(v => v / maxVal);
samples = normalizeWaveform(raw, 75);
}
}
} catch {}
Expand Down Expand Up @@ -302,10 +272,7 @@ async function saveAudio(streamUrl: string, isDownload: boolean, customOutFile:

let elapsed = 0;

const fmt = (ms: number) => {
const sec = Math.floor(ms / 1000);
return `${String(Math.floor(sec / 60)).padStart(2, "0")}:${String(sec % 60).padStart(2, "0")}`;
};
const fmt = formatTime;

const draw = (pct: number) => {
const timeStr = duration ? `${fmt(elapsed)} / ${fmt(duration)}` : `${Math.round(pct * 100)}%`;
Expand Down Expand Up @@ -403,7 +370,7 @@ async function downloadTrack(track: Track, clientId: string, oauthToken: string
}

const trackUrl = `https://soundcloud.com/${user.permalink}/${permalink}`;
const artwork = artwork_url?.replace(/-(large|t\d+x\d+)(?=\.\w+)/, "-original");
const artwork = artwork_url ? originalArtworkUrl(artwork_url) : undefined;

const metadata: AudioMetadata = {
title,
Expand Down
92 changes: 92 additions & 0 deletions lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Pure, side-effect-free helpers extracted from the downcloud CLI.
// Kept in a dedicated module so they can be unit-tested and benchmarked
// without importing the Bun-specific CLI entrypoint in `index.ts`.

/**
* Extract SoundCloud asset (`*.js`) URLs from the homepage HTML.
* These assets are scanned to discover the public `client_id`.
*/
export function extractAssetUrls(html: string): string[] {
const assetRegex = /src="(https:\/\/a-v2\.sndcdn\.com\/assets\/[^"]+\.js)"/g;
return Array.from(html.matchAll(assetRegex), m => m[1]).filter(
(x): x is string => x != null,
);
}

/**
* Find a `client_id` value embedded in an asset chunk, if present.
*/
export function findClientIdInChunk(chunk: string): string | null {
const match = chunk.match(/client_id:"([a-zA-Z0-9]+)"/);
return match ? match[1]! : null;
}

/**
* Downsample a waveform `samples` array to `targetWidth` buckets by averaging.
*/
export function compressWaveform(samples: number[], targetWidth: number): number[] {
const compressed: number[] = [];
const chunkSize = Math.ceil(samples.length / targetWidth);
for (let i = 0; i < samples.length; i += chunkSize) {
const chunk = samples.slice(i, i + chunkSize);
const avg = chunk.reduce((sum, v) => sum + v, 0) / chunk.length;
compressed.push(avg);
}
return compressed;
}

/**
* Downsample and normalize a waveform to values in the [0, 1] range.
*/
export function normalizeWaveform(samples: number[], targetWidth: number): number[] {
const compressed = compressWaveform(samples, targetWidth);
const maxVal = Math.max(...compressed) || 1;
return compressed.map(v => v / maxVal);
}

/**
* Render a two-row ASCII representation of a waveform.
*/
export function renderAsciiWaveform(
samples: number[],
targetWidth = 75,
): { top: string; bottom: string } {
const maxVal = Math.max(...samples) || 1;
const compressed = compressWaveform(samples, targetWidth);

const topSet = [" ", "▖", "▌"];
const botSet = [" ", "▘", "▌"];

const top = compressed
.map(v => {
const norm = v / maxVal;
const index = Math.min(Math.floor(norm * topSet.length), topSet.length - 1);
return topSet[index];
})
.join("");

const bottom = compressed
.map(v => {
const norm = v / maxVal;
const index = Math.min(Math.floor(norm * botSet.length), botSet.length - 1);
return botSet[index];
})
.join("");

return { top, bottom };
}

/**
* Rewrite an artwork URL to point at the original (largest) resolution.
*/
export function originalArtworkUrl(url: string): string {
return url.replace(/-(large|t\d+x\d+)(?=\.\w+)/, "-original");
}

/**
* Format a millisecond duration as `mm:ss`.
*/
export function formatTime(ms: number): string {
const sec = Math.floor(ms / 1000);
return `${String(Math.floor(sec / 60)).padStart(2, "0")}:${String(sec % 60).padStart(2, "0")}`;
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
"type": "module",
"version": "1.0.0",
"devDependencies": {
"@codspeed/vitest-plugin": "^5.6.0",
"@types/bun": "latest",
"@types/commander": "^2.12.5",
"bun2nix": "^2.1.0"
"bun2nix": "^2.1.0",
"vitest": "^4.1.9"
},
"peerDependencies": {
"typescript": "^5"
Expand Down
6 changes: 6 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineConfig } from "vitest/config";
import codspeed from "@codspeed/vitest-plugin";

export default defineConfig({
plugins: [codspeed()],
});
Loading