jevstrudel.git / packages / sampler / sample-server.mjs
1#!/usr/bin/env node
2
3import cowsay from 'cowsay';
4import { createReadStream, existsSync, writeFileSync } from 'fs';
5import { readdir } from 'fs/promises';
6import http from 'http';
7import { join, resolve, sep } from 'path';
8import readline from 'readline';
9import os from 'os';
10
11const LOG = !!process.env.LOG || false;
12const PORT = process.env.PORT || 5432;
13const VALID_AUDIO_EXTENSIONS = ['wav', 'mp3', 'ogg'];
14
15const isAudioFile = (f) => {
16  const ext = f.split('.').slice(-1)[0].toLowerCase();
17  return VALID_AUDIO_EXTENSIONS.includes(ext);
18};
19
20async function getFilesInDirectory(directory) {
21  let files = [];
22  const dirents = await readdir(directory, { withFileTypes: true });
23  for (const dirent of dirents) {
24    const fullPath = join(directory, dirent.name);
25    if (dirent.isDirectory()) {
26      if (dirent.name.startsWith('.')) {
27        LOG && console.warn(`ignore hidden folder: ${fullPath}`);
28        continue;
29      }
30      try {
31        const subFiles = (await getFilesInDirectory(fullPath)).filter(isAudioFile);
32        files = files.concat(subFiles);
33        LOG && console.log(`${dirent.name} (${subFiles.length})`);
34      } catch (err) {
35        LOG && console.warn(`skipped due to error: ${fullPath}`);
36      }
37    } else {
38      isAudioFile(fullPath) && files.push(fullPath);
39    }
40  }
41  return files;
42}
43
44async function getBanks(directory, flat = false) {
45  let files = await getFilesInDirectory(directory);
46  let banks = {};
47  directory = directory.split(sep).join('/');
48  files = files.map((path) => {
49    path = path.split(sep).join('/');
50    const subDir = path.replace(directory, '');
51    const subDirFlat = subDir.replaceAll('/', '_').slice(1); // remove initial underscore
52    const subDirFlatStem = subDirFlat.replace(/\.[^.]+$/, ''); // remove extension
53    let bank = flat ? subDirFlatStem : path.split('/').slice(-2)[0];
54    banks[bank] = banks[bank] || [];
55    banks[bank].push(subDir);
56    return subDir;
57  });
58  return { banks, files };
59}
60
61const args = process.argv.slice(2);
62
63function getArgValue(flag) {
64  const i = args.indexOf(flag);
65  if (i !== -1) {
66    const nextIsFlag = args[i + 1]?.startsWith('--') ?? true;
67    if (nextIsFlag) return true;
68    return args[i + 1];
69  }
70}
71
72function getInput(query) {
73  const rl = readline.createInterface({
74    input: process.stdin,
75    output: process.stdout,
76  });
77
78  return new Promise((resolve) =>
79    rl.question(query, (response) => {
80      rl.close();
81      resolve(response);
82    }),
83  );
84}
85
86let directory = getArgValue('--dir') || process.cwd();
87directory = resolve(directory);
88if (args.includes('--json')) {
89  const { banks } = await getBanks(directory, getArgValue('--flat'));
90  const json = JSON.stringify(banks);
91  const outFile = resolve(directory, 'strudel.json');
92  if (existsSync(outFile)) {
93    const answer = await getInput(`Warning: File already exists at ${outFile}. Overwrite? (y/N): `);
94    if (answer.toLowerCase() !== 'y') {
95      console.log('Aborted.');
96      process.exit(0);
97    }
98  }
99  writeFileSync(outFile, json, 'utf8');
100  console.log(`Wrote json to ${outFile}`);
101  process.exit(0);
102}
103
104console.log(
105  cowsay.say({
106    text: 'welcome to @strudel/sampler',
107    e: 'oO',
108    T: 'U ',
109  }),
110);
111
112const server = http.createServer(async (req, res) => {
113  res.setHeader('Access-Control-Allow-Origin', '*');
114  const { banks, files } = await getBanks(directory, getArgValue('--flat'));
115  if (req.url === '/') {
116    res.setHeader('Content-Type', 'application/json');
117    return res.end(JSON.stringify(banks));
118  }
119  let subpath = decodeURIComponent(req.url);
120  const filePath = join(directory, subpath.split('/').join(sep));
121
122  // console.log('GET:', filePath);
123  const isFound = existsSync(filePath);
124  if (!isFound) {
125    res.statusCode = 404;
126    res.end('File not found');
127    return;
128  }
129  const readStream = createReadStream(filePath);
130  readStream.on('error', (err) => {
131    res.statusCode = 500;
132    res.end('Internal server error');
133    console.error(err);
134  });
135  readStream.pipe(res);
136});
137
138const IP_ADDRESS = '0.0.0.0';
139let IP;
140const networkInterfaces = os.networkInterfaces();
141
142Object.keys(networkInterfaces).forEach((key) => {
143  networkInterfaces[key].forEach((networkInterface) => {
144    if (networkInterface.family === 'IPv4' && !networkInterface.internal) {
145      IP = networkInterface.address;
146    }
147  });
148});
149
150server.listen(PORT, IP_ADDRESS, () => {
151  console.log(`@strudel/sampler is now serving audio files from:
152 ${directory}
153
154To use them in the Strudel REPL, run:
155 samples('http://localhost:${PORT}')
156
157Or on a machine in the same network:
158 ${IP ? `samples('http://${IP}:${PORT}')` : `Unable to determine server's IP address.`}
159`);
160});