+
+
+
+
diff --git a/CloudServer/server.js b/CloudServer/server.js
new file mode 100644
index 0000000..9fa6cc5
--- /dev/null
+++ b/CloudServer/server.js
@@ -0,0 +1,2420 @@
+const express = require('express');
+const http = require('http');
+const WebSocket = require('ws');
+const os = require('os');
+const path = require('path');
+const bodyParser = require('body-parser');
+const multer = require('multer');
+const fs = require('fs');
+const { execSync, spawn } = require('child_process');
+const dfpwm = require('dfpwm');
+const { fetch } = require('undici');
+
+const app = express();
+const server = http.createServer(app);
+const wss = new WebSocket.Server({ server });
+
+const PORT = Number(process.env.PORT || '8999');
+const DEFAULT_ZH_MODEL = process.env.ZH_MODEL || '7b64d954530d490a8c40827e618e55c6';
+
+const HLS_ENABLE = process.env.HLS_ENABLE !== '0';
+const HLS_STREAM_MODE = (process.env.HLS_STREAM_MODE || 'event').toLowerCase();
+const HLS_OUTPUT_MODE = (process.env.HLS_OUTPUT_MODE || 'srs').toLowerCase();
+const SRS_RTMP_BASE = process.env.SRS_RTMP_BASE || 'rtmp://127.0.0.1/live';
+const STABROADCAST_PUBLIC_BASE_DEFAULT = 'https://stabroadcast.fse-media.group';
+const STABROADCAST_PUBLIC_BASE = process.env.STABROADCAST_PUBLIC_BASE || STABROADCAST_PUBLIC_BASE_DEFAULT;
+const STABROADCAST_HLS_SUFFIX = process.env.STABROADCAST_HLS_SUFFIX || '.m3u8';
+const HLS_TAIL_SILENCE_SEC = Number(process.env.HLS_TAIL_SILENCE_SEC || '0');
+const HLS_PREBUILD = process.env.HLS_PREBUILD !== '0';
+
+const tmpDir = path.join(__dirname, 'tmp');
+if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
+const silence1sWavPath = path.join(tmpDir, 'silence-1s.wav');
+
+const localHlsDir = path.join(__dirname, 'public', 'hls');
+if (!fs.existsSync(localHlsDir)) fs.mkdirSync(localHlsDir, { recursive: true });
+
+// Ensure upload directories exist
+const uploadDir = path.join(__dirname, 'public', 'uploads');
+const audioGeneralDir = path.join(uploadDir, 'audio', 'general');
+const audioStationDir = path.join(uploadDir, 'audio', 'station');
+const tempBroadcastDir = path.join(uploadDir, 'temp-broadcast');
+
+if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
+if (!fs.existsSync(audioGeneralDir)) fs.mkdirSync(audioGeneralDir, { recursive: true });
+if (!fs.existsSync(audioStationDir)) fs.mkdirSync(audioStationDir, { recursive: true });
+if (!fs.existsSync(tempBroadcastDir)) fs.mkdirSync(tempBroadcastDir, { recursive: true });
+
+// Multer storage configuration
+const storage = multer.diskStorage({
+ destination: function (req, file, cb) {
+ if (file.fieldname === 'listFile') {
+ cb(null, uploadDir);
+ } else if (file.fieldname === 'generalAudioFiles') {
+ cb(null, audioGeneralDir);
+ } else if (file.fieldname === 'stationAudioFiles') {
+ cb(null, audioStationDir);
+ } else {
+ cb(null, uploadDir);
+ }
+ },
+ filename: function (req, file, cb) {
+ if (file.fieldname === 'listFile') {
+ cb(null, 'list.txt');
+ } else {
+ // Use original name, handle encoding if needed (multer handles basic utf8)
+ file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8');
+ cb(null, file.originalname);
+ }
+ }
+});
+
+const upload = multer({ storage: storage });
+
+// Middleware
+app.use(bodyParser.json());
+app.use((req, res, next) => {
+ if (req.path.endsWith('.m3u8')) res.type('application/vnd.apple.mpegurl');
+ else if (req.path.endsWith('.ts')) res.type('video/mp2t');
+ next();
+});
+app.use(express.static(path.join(__dirname, 'public')));
+app.set('view engine', 'ejs');
+
+// Data Store (In-memory)
+let stations = {};
+const stationRouteIndex = new Map();
+let activeTrains = [];
+let stationLogs = [];
+let audioVersion = String(Date.now());
+let versions = [];
+
+const DATA_FILE = path.join(__dirname, 'server-data.json');
+
+const stationAudioQueues = new Map();
+
+function delay(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function getInterruptModeForPriority(priority) {
+ if (priority >= 2) return 'all';
+ if (priority >= 1) return 'ifLower';
+ return 'none';
+}
+
+function insertTaskByPriority(queue, task) {
+ const p = Number(task?.priority || 0);
+ let idx = 0;
+ while (idx < queue.length) {
+ const q = queue[idx];
+ const qp = Number(q?.priority || 0);
+ if (p > qp) break;
+ idx++;
+ }
+ queue.splice(idx, 0, task);
+}
+
+function getStationData(stationName) {
+ const cleanName = String(stationName || '').trim();
+ if (Object.prototype.hasOwnProperty.call(stations, cleanName)) return stations[cleanName];
+
+ // Try matching by code and platform if no direct name match
+ const targetCode = getStationCodeFromName(cleanName); // Already lowercase and normalized
+ const targetPlatform = getPlatformNumberFromName(cleanName);
+
+ if (targetCode) {
+ for (const k in stations) {
+ if (!Object.prototype.hasOwnProperty.call(stations, k)) continue;
+ const st = stations[k];
+ // Normalize st.code similarly to how targetCode was generated
+ const stCodeRaw = st.code || getStationCodeFromName(st.name) || '';
+ const stCode = stCodeRaw.replace(/_/g, '-').toLowerCase();
+
+ const stPlatform = getPlatformNumberFromName(st.name);
+ if (stCode === targetCode && stPlatform === targetPlatform) {
+ return st;
+ }
+ }
+ }
+ return null;
+}
+
+function getStationWs(stationName, fallbackWs) {
+ const st = getStationData(stationName);
+ if (st && st.ws && st.ws.readyState === WebSocket.OPEN) return st.ws;
+ if (fallbackWs && fallbackWs.readyState === WebSocket.OPEN) return fallbackWs;
+ return null;
+}
+
+function getMergedStationList() {
+ const list = [];
+ for (const k in stations) {
+ if (!Object.prototype.hasOwnProperty.call(stations, k)) continue;
+ const st = stations[k];
+ const stCodeRaw = st.code || getStationCodeFromName(st.name) || '';
+ const code = stCodeRaw.replace(/_/g, '-').toLowerCase();
+ list.push({
+ name: st.name,
+ code: code || '',
+ platform: st.platform || getPlatformNumberFromName(st.name),
+ lastSeen: st.lastSeen,
+ source: 'cc',
+ tags: ['CC']
+ });
+ }
+ return list;
+}
+
+function sendAudioControl(stationName, msg, fallbackWs) {
+ const ws = getStationWs(stationName, fallbackWs);
+ if (!ws) return false;
+ try {
+ ws.send(JSON.stringify(msg));
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function sendDfpwmBufferToStation(stationName, fallbackWs, task) {
+ const targetWs = getStationWs(stationName, fallbackWs);
+ if (!targetWs) throw new Error(`Station "${stationName}" not connected`);
+
+ const audioId = String(task.audioId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
+ const buf = task.buffer;
+ const durationSec = buf.length / 6000;
+
+ const startMsg = {
+ type: 'audio_start',
+ stationName,
+ audioId,
+ byteLength: buf.length,
+ sampleRate: 48000,
+ channels: 1,
+ priority: Number(task.priority || 0),
+ interruptMode: task.interruptMode || getInterruptModeForPriority(Number(task.priority || 0)),
+ source: task.source || 'unknown'
+ };
+
+ targetWs.send(JSON.stringify(startMsg));
+
+ const token = task.cancelToken;
+ const SINGLE_SEND_LIMIT = 32 * 1024;
+ const CHUNK_SIZE = 16 * 1024;
+
+ if (buf.length <= SINGLE_SEND_LIMIT) {
+ if (token && token.cancelled) return { audioId, cancelled: true, durationSec };
+ targetWs.send(buf);
+ } else {
+ for (let offset = 0; offset < buf.length; offset += CHUNK_SIZE) {
+ if (token && token.cancelled) return { audioId, cancelled: true, durationSec };
+ const end = Math.min(offset + CHUNK_SIZE, buf.length);
+ targetWs.send(buf.slice(offset, end));
+ // For streaming, we can send slightly faster initially then steady
+ const isInitial = offset < CHUNK_SIZE * 4;
+ await delay(isInitial ? 20 : 60);
+ }
+ }
+
+ if (token && token.cancelled) return { audioId, cancelled: true, durationSec };
+
+ targetWs.send(JSON.stringify({
+ type: 'audio_end',
+ stationName,
+ audioId,
+ durationSec
+ }));
+
+ return { audioId, cancelled: false, durationSec };
+}
+
+const stationMusicStates = new Map();
+
+function normalizeBaseName(name) {
+ const s = String(name || '').trim();
+ if (!s) return null;
+ const base = s.replace(/\.(wav|dfpwm)$/i, '').trim();
+ return base || null;
+}
+
+function listMusicTracks() {
+ const out = new Set();
+ if (!fs.existsSync(audioGeneralDir)) return [];
+
+ for (const file of fs.readdirSync(audioGeneralDir)) {
+ if (!file || file.startsWith('.')) continue;
+ const full = path.join(audioGeneralDir, file);
+ if (!fs.existsSync(full) || !fs.statSync(full).isFile()) continue;
+ if (!file.toLowerCase().endsWith('.wav') && !file.toLowerCase().endsWith('.dfpwm')) continue;
+ const base = normalizeBaseName(file);
+ if (base) out.add(base);
+ }
+
+ return Array.from(out).sort((a, b) => a.localeCompare(b));
+}
+
+function getDfpwmBufferByBaseName(baseName) {
+ const base = normalizeBaseName(baseName);
+ if (!base) throw new Error('Invalid track name');
+
+ const dfCandidates = [
+ path.join(audioGeneralDir, `${base}.dfpwm`),
+ path.join(audioStationDir, `${base}.dfpwm`)
+ ];
+ for (const p of dfCandidates) {
+ if (fs.existsSync(p)) return fs.readFileSync(p);
+ }
+
+ const wavCandidates = [
+ path.join(audioGeneralDir, `${base}.wav`),
+ path.join(audioStationDir, `${base}.wav`)
+ ];
+ const wavPath = wavCandidates.find(p => fs.existsSync(p)) || null;
+ if (!wavPath) throw new Error(`Audio not found: ${base}`);
+
+ const dfpwmPath = wavPath.replace(/\.wav$/i, '.dfpwm');
+ if (!fs.existsSync(dfpwmPath)) convertToDfpwm(wavPath);
+ if (!fs.existsSync(dfpwmPath)) throw new Error(`DFPWM build failed: ${base}`);
+ return fs.readFileSync(dfpwmPath);
+}
+
+function stopStationMusic(stationName, fallbackWs) {
+ const cleanStation = String(stationName || '').trim();
+ const q = getStationAudioQueue(cleanStation);
+
+ q.queue = q.queue.filter(t => t?.source !== 'music');
+
+ if (q.current && q.current.source === 'music') {
+ if (q.current.cancelToken) q.current.cancelToken.cancelled = true;
+ if (q.current.endTimer) {
+ try { clearTimeout(q.current.endTimer); } catch {}
+ q.current.endTimer = null;
+ }
+ }
+ sendAudioControl(cleanStation, { type: 'audio_stop', scope: 'music', mode: 'current', reason: 'music_stop' }, fallbackWs);
+}
+
+function getStationAudioQueue(stationName) {
+ const st = getStationData(stationName);
+ const key = st ? st.name : String(stationName || '').trim();
+
+ if (!stationAudioQueues.has(key)) {
+ stationAudioQueues.set(key, {
+ processing: false,
+ current: null,
+ queue: []
+ });
+ }
+ return stationAudioQueues.get(key);
+}
+
+function enqueueStationAudioTask(stationName, fallbackWs, task) {
+ const cleanStation = String(stationName || '').trim();
+ const q = getStationAudioQueue(cleanStation);
+ const newP = Number(task.priority || 0);
+ const cur = q.current;
+
+ if (task.interruptMode === 'all' || newP >= 2) {
+ if (cur && cur.cancelToken) cur.cancelToken.cancelled = true;
+ if (cur && cur.endTimer) {
+ try { clearTimeout(cur.endTimer); } catch {}
+ cur.endTimer = null;
+ }
+ q.queue.length = 0;
+ sendAudioControl(cleanStation, { type: 'audio_stop', mode: 'all', reason: 'emergency' }, fallbackWs);
+ } else if (cur) {
+ const curP = Number(cur.priority || 0);
+ if (newP > curP) {
+ if (cur.cancelToken) cur.cancelToken.cancelled = true;
+ if (cur.endTimer) {
+ try { clearTimeout(cur.endTimer); } catch {}
+ cur.endTimer = null;
+ }
+ sendAudioControl(cleanStation, { type: 'audio_stop', mode: 'current', flushBelow: newP, reason: 'preempt' }, fallbackWs);
+ if (cur.requeueOnPreempt) {
+ const replay = { ...cur, cancelToken: null };
+ replay.audioId = `${String(replay.audioId || '')}-replay-${Date.now()}`;
+ insertTaskByPriority(q.queue, replay);
+ }
+ }
+ }
+
+ insertTaskByPriority(q.queue, task);
+ processStationAudioQueue(cleanStation, fallbackWs).catch(() => {});
+}
+
+async function processStationAudioQueue(stationName, fallbackWs) {
+ const cleanStation = String(stationName || '').trim();
+ const q = getStationAudioQueue(cleanStation);
+ if (q.processing) return;
+ q.processing = true;
+ try {
+ while (q.queue.length > 0) {
+ const task = q.queue.shift();
+ task.cancelToken = { cancelled: false };
+ q.current = task;
+
+ try {
+ if (task.consoleStreamStart) broadcastToConsole(task.consoleStreamStart);
+ const result = await sendDfpwmBufferToStation(cleanStation, fallbackWs, task);
+ if (task.consoleStreamEnd) {
+ if (result.cancelled) {
+ broadcastToConsole(task.consoleStreamEnd);
+ } else {
+ task.endTimer = setTimeout(() => {
+ try { broadcastToConsole(task.consoleStreamEnd); } catch {}
+ }, Math.max(0, Number(result.durationSec || 0) * 1000));
+ }
+ }
+ if (task.onDone) task.onDone({ ...result, stationName: cleanStation });
+ } catch (e) {
+ if (task.consoleStreamEnd) broadcastToConsole(task.consoleStreamEnd);
+ if (task.onDone) task.onDone({ cancelled: true, error: e?.message || String(e), stationName: cleanStation });
+ } finally {
+ q.current = null;
+ }
+ }
+ } finally {
+ q.processing = false;
+ }
+}
+
+function loadData() {
+ if (fs.existsSync(DATA_FILE)) {
+ try {
+ const data = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
+ if (data.stations) stations = data.stations;
+ if (data.activeTrains) activeTrains = data.activeTrains;
+ if (data.audioVersion) audioVersion = String(data.audioVersion);
+ if (Array.isArray(data.versions)) versions = data.versions;
+ refreshStationIndex();
+ log('INFO', 'Data loaded from disk');
+ } catch (e) {
+ log('ERROR', `Failed to load data: ${e.message}`);
+ }
+ }
+}
+
+function saveData() {
+ try {
+ // Create a copy of stations without ws objects
+ const stationsToSave = {};
+ for (const key in stations) {
+ const { ws, ...rest } = stations[key];
+ stationsToSave[key] = rest;
+ }
+
+ const data = {
+ stations: stationsToSave,
+ activeTrains,
+ audioVersion,
+ versions
+ };
+ fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
+ } catch (e) {
+ log('ERROR', `Failed to save data: ${e.message}`);
+ }
+}
+
+function normalizeVersionString(input) {
+ if (input === undefined || input === null) return '';
+ if (typeof input !== 'string') input = String(input);
+ input = input.trim();
+ if (input.length > 64) input = input.slice(0, 64);
+ return input;
+}
+
+function getManagedFiles() {
+ const files = [];
+
+ const configPath = path.join(uploadDir, 'list.txt');
+ if (fs.existsSync(configPath)) {
+ const stats = fs.statSync(configPath);
+ files.push({
+ name: 'list.txt',
+ path: 'uploads/list.txt',
+ type: 'config',
+ size: stats.size,
+ mtime: stats.mtimeMs
+ });
+ }
+
+ if (fs.existsSync(audioGeneralDir)) {
+ fs.readdirSync(audioGeneralDir).forEach(file => {
+ if (file.endsWith('.dfpwm')) {
+ const wavName = file.replace(/\.dfpwm$/, '.wav');
+ if (fs.existsSync(path.join(audioGeneralDir, wavName))) return;
+ }
+ const filePath = path.join(audioGeneralDir, file);
+ const stats = fs.statSync(filePath);
+ files.push({
+ name: file,
+ path: `uploads/audio/general/${file}`,
+ type: 'general',
+ size: stats.size,
+ mtime: stats.mtimeMs
+ });
+ });
+ }
+
+ if (fs.existsSync(audioStationDir)) {
+ fs.readdirSync(audioStationDir).forEach(file => {
+ if (file.endsWith('.dfpwm')) {
+ const wavName = file.replace(/\.dfpwm$/, '.wav');
+ if (fs.existsSync(path.join(audioStationDir, wavName))) return;
+ }
+ const filePath = path.join(audioStationDir, file);
+ const stats = fs.statSync(filePath);
+ files.push({
+ name: file,
+ path: `uploads/audio/station/${file}`,
+ type: 'station',
+ size: stats.size,
+ mtime: stats.mtimeMs
+ });
+ });
+ }
+
+ return files;
+}
+
+function snapshotFiles() {
+ const snapshot = {};
+ const files = getManagedFiles();
+ for (const f of files) {
+ snapshot[f.path] = { mtime: f.mtime, size: f.size, type: f.type, name: f.name };
+ }
+ return snapshot;
+}
+
+function diffSnapshots(prevSnap, nextSnap) {
+ const prev = prevSnap || {};
+ const next = nextSnap || {};
+ const added = [];
+ const modified = [];
+ const deleted = [];
+
+ for (const p of Object.keys(next)) {
+ if (!prev[p]) added.push(p);
+ else if (prev[p].mtime !== next[p].mtime || prev[p].size !== next[p].size) modified.push(p);
+ }
+ for (const p of Object.keys(prev)) {
+ if (!next[p]) deleted.push(p);
+ }
+
+ added.sort();
+ modified.sort();
+ deleted.sort();
+
+ return { added, modified, deleted };
+}
+
+function log(type, message) {
+ const timestamp = new Date().toISOString();
+ const logEntry = `[${timestamp}] [${type}] ${message}`;
+ console.log(logEntry);
+ stationLogs.unshift(logEntry);
+ if (stationLogs.length > 100) stationLogs.pop();
+
+ // Broadcast logs to web console
+ broadcastToConsole({ type: 'log', data: logEntry });
+}
+
+const trainEventCooldown = new Map(); // trainId|stationName|type -> timestamp
+function normalizeName(value) {
+ return String(value || '').trim();
+}
+
+function getStationCodeFromName(name) {
+ if (!name) return null;
+
+ // 1. Try to find ID in parentheses or brackets (e.g. "Station (01-01/01-02)")
+ const parenMatch = name.match(/[\(\[]([a-zA-Z0-9\-\/]+)[\)\]]/);
+ if (parenMatch) return parenMatch[1].split('/')[0].replace('_', '-').trim().toLowerCase();
+
+ // 2. Try extraction from anywhere in string (e.g. "01-01" or "01_01")
+ const m = name.match(/([a-zA-Z0-9]+[-_][a-zA-Z0-9]+)/);
+ if (m) return m[1].replace('_', '-').toLowerCase();
+
+ // 3. Fallback to first part of name if it looks like a code or a name
+ const parts = name.split(/[\s?]+/);
+ const firstPart = parts[0];
+
+ // Ignore common generic parts
+ if (firstPart === 'Station' || firstPart === 'Platform' || firstPart === 'Track') {
+ if (parts[1]) return parts[1].toLowerCase();
+ }
+
+ if (firstPart && firstPart.length > 0) {
+ const code = firstPart.replace('_', '-').toLowerCase();
+ // log('DEBUG', `Extracted code "${code}" from "${name}"`);
+ return code;
+ }
+
+ return null;
+}
+
+function getPlatformNumberFromName(name) {
+ if (!name) return "1";
+ // 1. Try "Platform X" or "Track X" (case insensitive)
+ const m1 = name.match(/(?:platform|track)\s*([0-9]+[a-zA-Z]?)/i);
+ if (m1) return m1[1];
+
+ // 2. Fallback to digits at the end
+ const m2 = name.match(/([0-9]+[a-zA-Z]?)$/);
+ return m2 ? m2[1] : "1";
+}
+
+function normalizeStationCode(code) {
+ const value = String(code || '').trim().replace(/_/g, '-').toLowerCase();
+ return value || null;
+}
+
+function normalizeStationPlatform(platform) {
+ const value = String(platform || '').trim().toLowerCase();
+ return value || '1';
+}
+
+function getStationRouteKeys(name, code, platform) {
+ const keys = [];
+ const normalizedCode = normalizeStationCode(code || getStationCodeFromName(name));
+ const normalizedPlatform = normalizeStationPlatform(platform || getPlatformNumberFromName(name));
+ const baseName = getBaseName(name);
+
+ if (normalizedCode) {
+ keys.push(`code:${normalizedCode}|platform:${normalizedPlatform}`);
+ keys.push(`code:${normalizedCode}`);
+ }
+ if (baseName) {
+ keys.push(`base:${baseName}|platform:${normalizedPlatform}`);
+ keys.push(`base:${baseName}`);
+ }
+ return [...new Set(keys)];
+}
+
+function unindexStation(station) {
+ if (!station) return;
+ const keys = Array.isArray(station.routeKeys) ? station.routeKeys : [];
+ for (const key of keys) {
+ const current = stationRouteIndex.get(key);
+ if (current && current.name === station.name) {
+ stationRouteIndex.delete(key);
+ }
+ }
+ station.routeKeys = [];
+}
+
+function indexStation(station) {
+ if (!station || !station.name) return;
+ unindexStation(station);
+ station.platform = normalizeStationPlatform(station.platform || getPlatformNumberFromName(station.name));
+ station.code = normalizeStationCode(station.code || getStationCodeFromName(station.name)) || station.code || '';
+ station.routeKeys = getStationRouteKeys(station.name, station.code, station.platform);
+ for (const key of station.routeKeys) {
+ stationRouteIndex.set(key, station);
+ }
+}
+
+function refreshStationIndex() {
+ stationRouteIndex.clear();
+ for (const station of Object.values(stations)) {
+ indexStation(station);
+ }
+}
+
+function getBaseTrainId(trainId) {
+ if (!trainId) return "unknown";
+
+ // Ignore split off trains which are temporary and cause false announcements
+ if (trainId.includes("Split off from")) return null;
+
+ // Remove car-specific info like "01 3501F" from "CK 1 01 3501F"
+ // Also remove brackets like "[Local] Train 1" -> "Train 1"
+ let clean = trainId.replace(/^\[.*?\]\s*/, '');
+
+ // Look for patterns like "CK 1 01" and keep only "CK 1"
+ const parts = clean.split(/\s+/);
+ if (parts.length >= 3) {
+ // If it looks like "NAME NUM CAR ...", keep "NAME NUM"
+ if (parts[0].length <= 8 && !isNaN(parts[1])) {
+ return `${parts[0]} ${parts[1]}`;
+ }
+ }
+
+ // Handle dash patterns like "123-01" -> "123"
+ const dashMatch = parts[0].match(/^([\w\d]+)\-[\w\d]+$/);
+ if (dashMatch) return dashMatch[1];
+
+ return parts[0] + (parts[1] ? ' ' + parts[1] : '');
+}
+
+// Radio Listeners
+const radioListeners = {};
+const hlsStreams = {};
+
+function sanitizeStreamName(name) {
+ return String(name || '')
+ .trim()
+ .replace(/\s+/g, '_')
+ .replace(/[^a-zA-Z0-9_\-]/g, '_')
+ .replace(/_+/g, '_')
+ .replace(/^_+|_+$/g, '')
+ .slice(0, 80) || 'UNK';
+}
+
+function getPrimaryStationCode(code) {
+ if (!code) return null;
+ const first = String(code).split(/[,\s|/]+/).map(s => s.trim()).filter(Boolean)[0];
+ return first || null;
+}
+
+function getStationStreamName(stationName) {
+ const st = getStationData(stationName);
+ const primaryCode = getPrimaryStationCode(st?.code);
+ return sanitizeStreamName(primaryCode || stationName);
+}
+
+function getStationHlsUrl(streamName) {
+ const base = String(STABROADCAST_PUBLIC_BASE || '').replace(/\/+$/g, '');
+ return `${base}/${encodeURIComponent(streamName)}${STABROADCAST_HLS_SUFFIX}`;
+}
+
+function guessSrsHttpBase(req) {
+ const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim();
+ const hostRaw = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim();
+ if (!hostRaw) return null;
+
+ const m = hostRaw.match(/^(.+?)(?::(\d+))?$/);
+ const host = m?.[1] || hostRaw;
+ const port = m?.[2];
+
+ const withPort = port ? `${host}:8080` : `${host}:8080`;
+ return `${proto}://${withPort}/live`;
+}
+
+function getStationHlsUrlForRequest(streamName, req) {
+ const base = String(process.env.STABROADCAST_PUBLIC_BASE || guessSrsHttpBase(req) || STABROADCAST_PUBLIC_BASE_DEFAULT).replace(/\/+$/g, '');
+ return `${base}/${encodeURIComponent(streamName)}${STABROADCAST_HLS_SUFFIX}`;
+}
+
+function getLocalHlsUrlForRequest(streamName, req) {
+ const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim();
+ const hostRaw = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim();
+ const base = hostRaw ? `${proto}://${hostRaw}` : '';
+ return `${base}/hls/${encodeURIComponent(streamName)}/index.m3u8`;
+}
+
+function ensureSilenceWav() {
+ if (fs.existsSync(silence1sWavPath)) return;
+ execSync(`ffmpeg -y -v quiet -f lavfi -i anullsrc=r=48000:cl=mono -t 1 -c:a pcm_s16le "${silence1sWavPath}"`);
+}
+
+function getOrCreateHlsStreamState(streamName) {
+ if (!hlsStreams[streamName]) {
+ hlsStreams[streamName] = {
+ streamName,
+ stationName: null,
+ stationCode: null,
+ hlsUrl: getStationHlsUrl(streamName),
+ status: 'idle',
+ lastStartAt: null,
+ lastEndAt: null,
+ lastSeq: null,
+ lastError: null,
+ lastExitCode: null,
+ queue: Promise.resolve(),
+ currentProcess: null
+ };
+ }
+ return hlsStreams[streamName];
+}
+
+function buildConcatListFile(seq) {
+ ensureSilenceWav();
+ const files = [];
+
+ for (const item of seq) {
+ if (item === '__DELAY_1__') {
+ files.push(silence1sWavPath);
+ continue;
+ }
+
+ const candidates = [
+ path.join(audioStationDir, `${item}.wav`),
+ path.join(audioGeneralDir, `${item}.wav`)
+ ];
+
+ const found = candidates.find(p => fs.existsSync(p));
+ if (found) {
+ files.push(found);
+ } else {
+ log('WARN', `HLS audio missing on server (wav required): ${item}`);
+ }
+ }
+
+ if (HLS_TAIL_SILENCE_SEC > 0) {
+ for (let i = 0; i < HLS_TAIL_SILENCE_SEC; i++) files.push(silence1sWavPath);
+ }
+
+ if (files.length === 0) return null;
+
+ const listPath = path.join(tmpDir, `concat-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`);
+ const content = files
+ .map(p => `file '${p.replace(/\\/g, '/').replace(/'/g, "\\'")}'`)
+ .join('\n');
+ fs.writeFileSync(listPath, content, 'utf8');
+ return listPath;
+}
+
+function runFfmpegRtmpPush(streamName, seq) {
+ const state = getOrCreateHlsStreamState(streamName);
+ const listPath = buildConcatListFile(seq);
+ if (!listPath) {
+ state.status = 'idle';
+ state.lastError = 'No playable wav files in sequence';
+ return Promise.resolve();
+ }
+
+ const rtmpUrl = `${SRS_RTMP_BASE.replace(/\/+$/g, '')}/${encodeURIComponent(streamName)}`;
+
+ state.lastSeq = seq;
+ state.lastError = null;
+ state.lastExitCode = null;
+
+ if (HLS_PREBUILD) {
+ const tmpAacPath = path.join(tmpDir, `prebuild-${Date.now()}-${Math.random().toString(16).slice(2)}.aac`);
+ const buildArgs = [
+ '-hide_banner',
+ '-loglevel',
+ 'error',
+ '-y',
+ '-f',
+ 'concat',
+ '-safe',
+ '0',
+ '-i',
+ listPath,
+ '-ac',
+ '1',
+ '-ar',
+ '48000',
+ '-c:a',
+ 'aac',
+ '-b:a',
+ '128k',
+ '-f',
+ 'adts',
+ tmpAacPath
+ ];
+
+ state.status = 'building';
+ state.lastStartAt = Date.now();
+
+ const buildProc = spawn('ffmpeg', buildArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
+ let buildStderr = '';
+ buildProc.stderr.on('data', (buf) => {
+ buildStderr += buf.toString('utf8');
+ if (buildStderr.length > 8000) buildStderr = buildStderr.slice(-8000);
+ });
+
+ return new Promise((resolve) => {
+ buildProc.on('close', (buildCode) => {
+ if (buildCode !== 0) {
+ state.lastExitCode = buildCode;
+ state.status = 'idle';
+ state.lastEndAt = Date.now();
+ state.lastError = (buildStderr || `ffmpeg exited with code ${buildCode}`).trim();
+ log('ERROR', `HLS build failed for ${streamName}: ${state.lastError}`);
+ try { fs.unlinkSync(listPath); } catch {}
+ try { fs.unlinkSync(tmpAacPath); } catch {}
+ resolve();
+ return;
+ }
+
+ const pushArgs = [
+ '-hide_banner',
+ '-loglevel',
+ 'error',
+ '-re',
+ '-i',
+ tmpAacPath,
+ '-c:a',
+ 'copy',
+ '-f',
+ 'flv',
+ rtmpUrl
+ ];
+
+ state.status = 'pushing';
+ const proc = spawn('ffmpeg', pushArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
+ state.currentProcess = proc;
+
+ let stderr = '';
+ proc.stderr.on('data', (buf) => {
+ stderr += buf.toString('utf8');
+ if (stderr.length > 8000) stderr = stderr.slice(-8000);
+ });
+
+ proc.on('close', (code) => {
+ state.lastExitCode = code;
+ state.status = 'idle';
+ state.lastEndAt = Date.now();
+ state.currentProcess = null;
+ if (code !== 0) {
+ state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim();
+ log('ERROR', `HLS push failed for ${streamName}: ${state.lastError}`);
+ }
+ try { fs.unlinkSync(listPath); } catch {}
+ try { fs.unlinkSync(tmpAacPath); } catch {}
+ resolve();
+ });
+ });
+ });
+ }
+
+ const args = [
+ '-hide_banner',
+ '-loglevel',
+ 'error',
+ '-re',
+ '-f',
+ 'concat',
+ '-safe',
+ '0',
+ '-i',
+ listPath,
+ '-ac',
+ '1',
+ '-ar',
+ '48000',
+ '-c:a',
+ 'aac',
+ '-b:a',
+ '128k',
+ '-f',
+ 'flv',
+ rtmpUrl
+ ];
+
+ state.status = 'pushing';
+ state.lastStartAt = Date.now();
+
+ const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
+ state.currentProcess = proc;
+
+ let stderr = '';
+ proc.stderr.on('data', (buf) => {
+ stderr += buf.toString('utf8');
+ if (stderr.length > 8000) stderr = stderr.slice(-8000);
+ });
+
+ return new Promise((resolve) => {
+ proc.on('close', (code) => {
+ state.lastExitCode = code;
+ state.status = 'idle';
+ state.lastEndAt = Date.now();
+ state.currentProcess = null;
+ if (code !== 0) {
+ state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim();
+ log('ERROR', `HLS push failed for ${streamName}: ${state.lastError}`);
+ }
+ try { fs.unlinkSync(listPath); } catch {}
+ resolve();
+ });
+ });
+}
+
+function clearLocalHlsStreamDir(streamName) {
+ const dir = path.join(localHlsDir, streamName);
+ if (!fs.existsSync(dir)) return dir;
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+ for (const ent of entries) {
+ const p = path.join(dir, ent.name);
+ if (ent.isDirectory()) {
+ fs.rmSync(p, { recursive: true, force: true });
+ } else {
+ try { fs.unlinkSync(p); } catch {}
+ }
+ }
+ return dir;
+}
+
+function runFfmpegLocalHlsBuild(streamName, seq) {
+ const state = getOrCreateHlsStreamState(streamName);
+ const listPath = buildConcatListFile(seq);
+ if (!listPath) {
+ state.status = 'idle';
+ state.lastError = 'No playable wav files in sequence';
+ return Promise.resolve();
+ }
+
+ state.lastSeq = seq;
+ state.lastError = null;
+ state.lastExitCode = null;
+ state.status = 'building';
+ state.lastStartAt = Date.now();
+
+ const outDir = clearLocalHlsStreamDir(streamName);
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
+
+ const playlistPath = path.join(outDir, 'index.m3u8');
+ const segPattern = path.join(outDir, 'seg-%05d.ts');
+ const args = [
+ '-hide_banner',
+ '-loglevel',
+ 'error',
+ '-y',
+ '-f',
+ 'concat',
+ '-safe',
+ '0',
+ '-i',
+ listPath,
+ '-ac',
+ '1',
+ '-ar',
+ '48000',
+ '-c:a',
+ 'aac',
+ '-b:a',
+ '128k',
+ '-f',
+ 'hls',
+ '-hls_time',
+ '2',
+ '-hls_list_size',
+ '0',
+ '-hls_playlist_type',
+ 'vod',
+ '-hls_flags',
+ 'independent_segments',
+ '-hls_segment_filename',
+ segPattern,
+ playlistPath
+ ];
+
+ const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
+ state.currentProcess = proc;
+
+ let stderr = '';
+ proc.stderr.on('data', (buf) => {
+ stderr += buf.toString('utf8');
+ if (stderr.length > 8000) stderr = stderr.slice(-8000);
+ });
+
+ return new Promise((resolve) => {
+ proc.on('close', (code) => {
+ state.lastExitCode = code;
+ state.status = 'idle';
+ state.lastEndAt = Date.now();
+ state.currentProcess = null;
+ if (code !== 0) {
+ state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim();
+ log('ERROR', `Local HLS build failed for ${streamName}: ${state.lastError}`);
+ }
+ try { fs.unlinkSync(listPath); } catch {}
+ resolve();
+ });
+ });
+}
+
+function enqueueHlsPush(stationName, seq) {
+ if (!HLS_ENABLE) return;
+ if (!Array.isArray(seq) || seq.length === 0) return;
+
+ const streamName = getStationStreamName(stationName);
+ const st = getStationData(stationName);
+ const state = getOrCreateHlsStreamState(streamName);
+ state.stationName = stationName;
+ state.stationCode = getPrimaryStationCode(st?.code);
+ state.hlsUrl = state.hlsUrl || getStationHlsUrl(streamName);
+
+ if (HLS_STREAM_MODE === 'replace' && state.currentProcess) {
+ try { state.currentProcess.kill('SIGKILL'); } catch {}
+ }
+
+ state.queue = state.queue
+ .then(() => (HLS_OUTPUT_MODE === 'local' ? runFfmpegLocalHlsBuild(streamName, seq) : runFfmpegRtmpPush(streamName, seq)))
+ .catch((e) => {
+ state.status = 'idle';
+ state.lastError = e?.message || String(e);
+ log('ERROR', `HLS push error for ${streamName}: ${state.lastError}`);
+ });
+}
+
+// WebSocket handling
+wss.on('connection', (ws, req) => {
+ const ip = req.socket.remoteAddress;
+
+ // Check if it's a radio connection
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
+ if (url.pathname === '/api/radio') {
+ const station = url.searchParams.get('station');
+ if (station) {
+ const cleanStation = station.trim();
+ if (!radioListeners[cleanStation]) radioListeners[cleanStation] = [];
+
+ // To prevent zombie connections, close existing connections from the same IP
+ // Since CC might reconnect quickly and leave old ones hanging
+ radioListeners[cleanStation] = radioListeners[cleanStation].filter(existingWs => {
+ if (existingWs.readyState !== WebSocket.OPEN) return false;
+ // If it's the same IP connecting again, close the old one to save CC resources
+ if (existingWs._socket && req.socket && existingWs._socket.remoteAddress === req.socket.remoteAddress) {
+ existingWs.close();
+ return false;
+ }
+ return true;
+ });
+
+ radioListeners[cleanStation].push(ws);
+ log('INFO', `Radio listener connected for station: ${cleanStation}`);
+
+ // Send an empty initial frame to let the client know it's connected and ready
+ // DFPWM silence is 0xAA. 24000 bytes = 4 seconds of silence to pre-fill buffer.
+ ws.send(Buffer.alloc(24000, 0xAA));
+
+ ws.on('close', () => {
+ radioListeners[cleanStation] = radioListeners[cleanStation].filter(l => l !== ws);
+ log('INFO', `Radio listener disconnected for station: ${cleanStation}`);
+ });
+
+ // Ping to keep alive
+ const pingInterval = setInterval(() => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.ping();
+ } else {
+ clearInterval(pingInterval);
+ }
+ }, 30000);
+
+ ws.on('close', () => clearInterval(pingInterval));
+ return;
+ }
+ }
+
+ ws.on('message', (message) => {
+ try {
+ const data = JSON.parse(message);
+ handleMessage(ws, data);
+ } catch (e) {
+ log('ERROR', `Failed to parse message: ${e.message}`);
+ }
+ });
+
+ ws.on('close', () => {
+ // Handle disconnection if necessary
+ });
+});
+
+function handleMessage(ws, data) {
+ // Check if it's a console connection
+ if (data.type === 'console_connect') {
+ ws.isConsole = true;
+ // Send initial state
+ ws.send(JSON.stringify({
+ type: 'init_state',
+ stations,
+ activeTrains,
+ logs: stationLogs
+ }));
+ return;
+ }
+
+ // Handle Station Messages
+ switch (data.type) {
+ case 'station_register':
+ handleStationRegister(ws, data);
+ break;
+ case 'train_arrival':
+ handleTrainArrival(data);
+ break;
+ case 'train_departure':
+ handleTrainDeparture(data);
+ break;
+ case 'request_station_time':
+ handleStationTimeRequest(ws, data);
+ break;
+ case 'play_sequence':
+ handlePlaySequence(ws, data);
+ break;
+ case 'request_list_txt':
+ // TODO: Send list.txt content
+ break;
+ case 'heartbeat':
+ handleHeartbeat(ws, data);
+ break;
+ default:
+ log('WARNING', `Unknown message type: ${data.type}`);
+ }
+}
+
+function getDestinationFromSchedule(schedule) {
+ if (!schedule || !schedule.entries) return "Unknown";
+ const entries = schedule.entries;
+ let dest = "Unknown";
+
+ // Convert entries to array if it's an object with numeric keys
+ const entryList = Array.isArray(entries) ? entries : Object.values(entries);
+
+ // 1. Look for explicit destination instructions
+ for (const entry of entryList) {
+ if (entry.instruction?.id === 'create:destination') {
+ dest = entry.instruction.data?.text || entry.instruction.text || dest;
+ }
+ }
+
+ // 2. If still Unknown, find the very last station instruction
+ if (dest === "Unknown") {
+ for (let i = entryList.length - 1; i >= 0; i--) {
+ if (entryList[i].instruction?.id === 'create:station') {
+ dest = entryList[i].instruction.data?.text || entryList[i].instruction.text || dest;
+ break;
+ }
+ }
+ }
+ return dest;
+}
+
+function getCorrectStationName(name) {
+ const cleanName = String(name || '').trim();
+ const station = findStationByFlexibleName(cleanName);
+ if (station && station.name) {
+ return station.name;
+ }
+ return cleanName;
+}
+
+function handleHeartbeat(ws, data) {
+ if (data.stationName) {
+ const cleanName = data.stationName.trim();
+ if (!stations[cleanName]) {
+ // Auto-register if missing (e.g. server restarted)
+ stations[cleanName] = {
+ name: cleanName,
+ code: data.stationCode || '',
+ platform: data.stationPlatform || getPlatformNumberFromName(cleanName),
+ lastSeen: Date.now(),
+ ws: ws
+ };
+ indexStation(stations[cleanName]);
+ log('INFO', `Station auto-registered via heartbeat: "${cleanName}"`);
+ broadcastUpdate();
+ } else {
+ stations[cleanName].lastSeen = Date.now();
+ if (data.stationCode) stations[cleanName].code = data.stationCode;
+ if (data.stationPlatform) stations[cleanName].platform = data.stationPlatform;
+ if (stations[cleanName].ws !== ws) {
+ stations[cleanName].ws = ws; // Update WS in case of reconnect
+ log('INFO', `Station WebSocket updated via heartbeat: "${cleanName}"`);
+ broadcastUpdate();
+ }
+ indexStation(stations[cleanName]);
+ }
+ }
+}
+
+function handleStationRegister(ws, data) {
+ const { stationName, stationCode, stationPlatform } = data;
+ const cleanName = stationName.trim();
+
+ stations[cleanName] = {
+ name: cleanName,
+ code: stationCode,
+ platform: stationPlatform || getPlatformNumberFromName(cleanName),
+ lastSeen: Date.now(),
+ ws: ws // Keep reference if we need to send back
+ };
+ indexStation(stations[cleanName]);
+
+ log('SUCCESS', `Station registered: "${cleanName}" (${stationCode})`);
+
+ // Send ack
+ ws.send(JSON.stringify({
+ type: 'station_register_ack',
+ stationName: cleanName
+ }));
+
+ saveData();
+ broadcastUpdate();
+}
+
+function handleTrainArrival(data) {
+ let { trainId, stationName, arrivalTime, destination, source, schedule } = data;
+ stationName = getCorrectStationName(stationName);
+ const baseId = getBaseTrainId(trainId);
+
+ const cooldownKey = `${baseId}|${stationName}|arrival`;
+ const lastEventTime = trainEventCooldown.get(cooldownKey);
+ if (lastEventTime && Date.now() - lastEventTime < 45000) return;
+ trainEventCooldown.set(cooldownKey, Date.now());
+
+ log('INFO', `Train ${trainId} (Consist ${baseId}) arrived at ${stationName}`);
+
+ // Add or update active train using base ID to group cars
+ const existingIndex = activeTrains.findIndex(t => getBaseTrainId(t.trainId) === baseId);
+
+ // Create history entry
+ const historyEntry = {
+ station: stationName,
+ action: 'Arrival',
+ time: Date.now()
+ };
+
+ let trainData = {
+ trainId,
+ currentStation: stationName,
+ destination: destination || (existingIndex >= 0 ? activeTrains[existingIndex].destination : "Unknown"),
+ status: "At Station",
+ lastUpdate: Date.now(),
+ schedule: schedule || (existingIndex >= 0 ? activeTrains[existingIndex].schedule : null),
+ tripLogs: existingIndex >= 0 ? (activeTrains[existingIndex].tripLogs || []) : []
+ };
+
+ trainData.tripLogs.push(historyEntry);
+
+ if (existingIndex >= 0) {
+ activeTrains[existingIndex] = { ...activeTrains[existingIndex], ...trainData };
+ } else {
+ activeTrains.push(trainData);
+ }
+
+ broadcastUpdate();
+}
+
+function getBaseName(name) {
+ if (!name) return "";
+ return String(name).toLowerCase()
+ .replace(/^[\d\w]+[-_][\d\w]+\s+/, '') // Strip leading code "01-01 "
+ .replace(/\s*platform\s*[\d\w]+/gi, '')
+ .replace(/\s*track\s*[\d\w]+/gi, '')
+ .replace(/\s*站台\s*[\d\w]+/gi, '')
+ .replace(/\s*股道\s*[\d\w]+/gi, '')
+ .replace(/@/g, ' ')
+ .replace(/\s*(station|站|tread|track)$/gi, '')
+ .replace(/[\(\[].*?[\)\]]/g, ' ')
+ .replace(/\s+\d+$/g, '')
+ .replace(/[^\w\s\u4e00-\u9fa5]/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+function isFuzzyMatch(str, pattern) {
+ if (!str || !pattern) return false;
+ const s = String(str).toLowerCase().trim();
+ const p = String(pattern).toLowerCase().trim();
+
+ if (s === p) return true;
+
+ // 1. Wildcard match
+ if (p.includes('*')) {
+ const regex = new RegExp('^' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*') + '$', 'i');
+ if (regex.test(s)) return true;
+ }
+
+ // 2. Base name comparison
+ const b1 = getBaseName(s);
+ const b2 = getBaseName(p);
+ if (b1 && b2 && b1 === b2) return true;
+
+ // 3. Substring match of base names (if one is long enough)
+ if (b1 && b2) {
+ if (b1.length >= 4 && b2.length >= 4) {
+ if (b1.includes(b2) || b2.includes(b1)) return true;
+ }
+ }
+
+ // 4. Stripped comparison
+ const s1 = s.replace(/[\s\-\._\(\)\[\]]/g, '');
+ const s2 = p.replace(/[\s\-\._\(\)\[\]]/g, '');
+ if (s1 && s2 && s1 === s2) return true;
+
+ // 5. Station code comparison
+ const c1 = getStationCodeFromName(s);
+ const c2 = getStationCodeFromName(p);
+ if (c1 && c2 && c1 === c2) {
+ // Only trust code match if it looks like a real code (contains numbers or is very short)
+ const isRealCode = (str) => /[\d]/.test(str) || str.length <= 4;
+ if (isRealCode(c1)) return true;
+
+ // If it's a long name-based code, also require base name similarity
+ if (b1 && b2 && (b1.includes(b2) || b2.includes(b1))) return true;
+ }
+
+ return false;
+}
+
+function findStationByFlexibleName(targetName) {
+ if (!targetName) return null;
+
+ const directKeys = getStationRouteKeys(targetName);
+ for (const key of directKeys) {
+ const station = stationRouteIndex.get(key);
+ if (station) return station;
+ }
+
+ // 1. Try exact match
+ if (stations[targetName]) return stations[targetName];
+
+ // 2. Search through registered stations using fuzzy match
+ for (const [regName, data] of Object.entries(stations)) {
+ if (isFuzzyMatch(regName, targetName)) {
+ // log('DEBUG', `[Station-Search] Matched "${targetName}" to registered "${regName}"`);
+ return data;
+ }
+ }
+
+ // Log available stations for debugging
+ const available = Object.keys(stations).map(k => `"${k}" (base: "${getBaseName(k)}")`).join(', ');
+ log('DEBUG', `[Station-Search] Could not find match for "${targetName}" (base: "${getBaseName(targetName)}"). Available: [${available}]`);
+ return null;
+}
+
+function handleTrainDeparture(data) {
+ let { trainId, stationName, destination, schedule, source } = data;
+ stationName = getCorrectStationName(stationName);
+ const baseId = getBaseTrainId(trainId);
+
+ const cooldownKey = `${baseId}|${stationName}|departure`;
+ const lastEventTime = trainEventCooldown.get(cooldownKey);
+ if (lastEventTime && Date.now() - lastEventTime < 45000) return;
+ trainEventCooldown.set(cooldownKey, Date.now());
+
+ log('INFO', `Train ${trainId} (Consist ${baseId}) departed from ${stationName}`);
+
+ const existingIndex = activeTrains.findIndex(t => getBaseTrainId(t.trainId) === baseId);
+ if (existingIndex >= 0) {
+ const train = activeTrains[existingIndex];
+ train.status = "In Transit";
+ train.currentStation = "Transit (" + stationName + " -> " + (destination || "?") + ")";
+ train.lastUpdate = Date.now();
+ train.lastStation = stationName;
+ train.departureTime = Date.now();
+ train.destination = destination || train.destination;
+ train.schedule = schedule || train.schedule;
+
+ if (!train.tripLogs) train.tripLogs = [];
+ train.tripLogs.push({
+ station: stationName,
+ action: 'Departure',
+ time: Date.now()
+ });
+ }
+ broadcastUpdate();
+}
+
+function handlePlaySequence(ws, data) {
+ const { stationName, seq, requestId } = data;
+ const cleanStation = stationName.trim();
+ const correctStation = getCorrectStationName(cleanStation);
+ const streamName = getStationStreamName(cleanStation);
+ const hlsUrl = getStationHlsUrl(streamName);
+
+ log('INFO', `Playing sequence for ${correctStation}: ${seq.join(', ')}`);
+
+ // Process files sequentially
+ let combinedBuffer = [];
+ for (const file of seq) {
+ if (file === '__DELAY_1__') {
+ const silence = Buffer.alloc(6000, 0xAA);
+ combinedBuffer.push(silence);
+ continue;
+ }
+
+ let foundPath = null;
+
+ const candidates = [
+ path.join(audioStationDir, `${file}.dfpwm`),
+ path.join(audioGeneralDir, `${file}.dfpwm`),
+ path.join(audioStationDir, `${file}.wav`),
+ path.join(audioGeneralDir, `${file}.wav`)
+ ];
+
+ for (const p of candidates) {
+ if (fs.existsSync(p)) {
+ foundPath = p;
+ break;
+ }
+ }
+
+ if (foundPath) {
+ let dfpwmPath = foundPath;
+ if (foundPath.endsWith('.wav')) {
+ dfpwmPath = foundPath.replace(/\.wav$/, '.dfpwm');
+ if (!fs.existsSync(dfpwmPath)) {
+ convertToDfpwm(foundPath);
+ }
+ }
+
+ if (fs.existsSync(dfpwmPath)) {
+ combinedBuffer.push(fs.readFileSync(dfpwmPath));
+ } else {
+ log('WARN', `Could not get DFPWM for: ${file}`);
+ }
+ } else {
+ log('WARN', `Audio missing on server: ${file}`);
+ }
+ }
+
+ if (combinedBuffer.length > 0) {
+ const finalBuffer = Buffer.concat(combinedBuffer);
+ const priority = Number.isFinite(Number(data.priority))
+ ? Number(data.priority)
+ : (String(data.category || '').toLowerCase() === 'station' ? 0 : 1);
+ const interruptMode = data.interruptMode || getInterruptModeForPriority(priority);
+ const source = data.source || data.category || 'sequence';
+
+ const audioId = String(requestId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
+
+ enqueueStationAudioTask(cleanStation, ws, {
+ buffer: finalBuffer,
+ priority,
+ interruptMode,
+ source,
+ audioId,
+ requeueOnPreempt: priority <= 0,
+ consoleStreamStart: { type: 'stream_start', station: cleanStation, seq: seq, streamName, hlsUrl },
+ consoleStreamEnd: { type: 'stream_end', station: cleanStation }
+ });
+ }
+}
+
+function handleStationTimeRequest(ws, data) {
+ // Simple logic: default to 60s or lookup schedule
+ const stationTime = 60;
+ ws.send(JSON.stringify({
+ type: 'station_time_response',
+ trainId: data.trainId,
+ stationTime: stationTime
+ }));
+}
+
+function broadcastToConsole(msg) {
+ wss.clients.forEach(client => {
+ if (client.readyState === WebSocket.OPEN && client.isConsole) {
+ client.send(JSON.stringify(msg));
+ }
+ });
+}
+
+function broadcastUpdate() {
+ const list = getMergedStationList().sort((a, b) => a.name.localeCompare(b.name));
+
+ const state = {
+ type: 'state_update',
+ stations: list,
+ activeTrains
+ };
+ broadcastToConsole(state);
+}
+
+app.get('/api/streams', (req, res) => {
+ const streams = [];
+ const seen = new Set();
+ const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim();
+ const hostRaw = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim();
+ const localBase = hostRaw ? `${proto}://${hostRaw}/hls` : '/hls';
+ const publicBaseResolved = HLS_OUTPUT_MODE === 'local'
+ ? localBase
+ : String(process.env.STABROADCAST_PUBLIC_BASE || guessSrsHttpBase(req) || STABROADCAST_PUBLIC_BASE_DEFAULT);
+
+ Object.keys(stations).forEach(stationName => {
+ const st = stations[stationName];
+ const streamName = getStationStreamName(stationName);
+ const state = getOrCreateHlsStreamState(streamName);
+ state.stationName = stationName;
+ state.stationCode = getPrimaryStationCode(st?.code);
+ state.hlsUrl = HLS_OUTPUT_MODE === 'local'
+ ? getLocalHlsUrlForRequest(streamName, req)
+ : getStationHlsUrlForRequest(streamName, req);
+ seen.add(streamName);
+
+ streams.push({
+ streamName,
+ stationName: state.stationName,
+ stationCode: state.stationCode,
+ hlsUrl: state.hlsUrl,
+ status: state.status,
+ lastStartAt: state.lastStartAt,
+ lastEndAt: state.lastEndAt,
+ lastSeq: state.lastSeq,
+ lastError: state.lastError,
+ lastExitCode: state.lastExitCode
+ });
+ });
+
+ Object.values(hlsStreams).forEach(state => {
+ if (seen.has(state.streamName)) return;
+ streams.push({
+ streamName: state.streamName,
+ stationName: state.stationName,
+ stationCode: state.stationCode,
+ hlsUrl: HLS_OUTPUT_MODE === 'local'
+ ? getLocalHlsUrlForRequest(state.streamName, req)
+ : getStationHlsUrlForRequest(state.streamName, req),
+ status: state.status,
+ lastStartAt: state.lastStartAt,
+ lastEndAt: state.lastEndAt,
+ lastSeq: state.lastSeq,
+ lastError: state.lastError,
+ lastExitCode: state.lastExitCode
+ });
+ });
+
+ streams.sort((a, b) => String(a.streamName).localeCompare(String(b.streamName)));
+
+ res.json({
+ ok: true,
+ hlsEnable: HLS_ENABLE,
+ streamMode: HLS_STREAM_MODE,
+ publicBase: publicBaseResolved,
+ srsRtmpBase: SRS_RTMP_BASE,
+ streams
+ });
+});
+
+app.get('/stablist', (req, res) => {
+ res.redirect('/stablist.html');
+});
+
+// Routes for direct API access if needed
+app.get('/', (req, res) => {
+ const host = String(req.headers.host || '').toLowerCase();
+ if (host.startsWith('stablist.fse-media.group')) {
+ res.sendFile(path.join(__dirname, 'public', 'stablist.html'));
+ return;
+ }
+ res.sendFile(path.join(__dirname, 'public', 'index.html'));
+});
+
+function convertToDfpwm(filePath) {
+ try {
+ if (!filePath.toLowerCase().endsWith('.wav')) return;
+
+ const rawPath = filePath.replace(/\.wav$/i, '.raw');
+ const dfpwmPath = filePath.replace(/\.wav$/i, '.dfpwm');
+
+ // Convert to raw signed 8-bit 48kHz mono using FFmpeg
+ // -y: overwrite output
+ // -v quiet: suppress output
+ // -f s8: signed 8-bit pcm
+ // -ac 1: mono
+ // -ar 48000: 48kHz
+ execSync(`ffmpeg -y -v quiet -i "${filePath}" -f s8 -acodec pcm_s8 -ac 1 -ar 48000 "${rawPath}"`);
+
+ if (fs.existsSync(rawPath)) {
+ const buffer = fs.readFileSync(rawPath);
+
+ // dfpwm.Encoder.encode requires a Buffer in Node.js
+ const encoder = new dfpwm.Encoder();
+
+ // We need to pass the raw buffer to the encoder. The buffer already contains 8-bit PCM data.
+ // Some versions of dfpwm throw "Argument #1 must be a Buffer" if it's an Array or TypedArray.
+ const encoded = encoder.encode(buffer);
+
+ fs.writeFileSync(dfpwmPath, encoded);
+ fs.unlinkSync(rawPath); // Clean up temp file
+
+ log('SUCCESS', `Converted ${path.basename(filePath)} to DFPWM (FFmpeg)`);
+ } else {
+ log('ERROR', `FFmpeg failed to generate raw file for ${path.basename(filePath)}`);
+ }
+
+ } catch (e) {
+ log('ERROR', `DFPWM conversion failed for ${path.basename(filePath)}: ${e.message}`);
+ }
+}
+
+// Routes
+app.get('/api/version', (req, res) => {
+ res.json({ version: audioVersion });
+});
+
+app.post('/api/publish-version', (req, res) => {
+ const version = normalizeVersionString(req.body?.version) || String(Date.now());
+ const snap = snapshotFiles();
+ const prev = versions.length > 0 ? versions[versions.length - 1].snapshot : null;
+ const changes = diffSnapshots(prev, snap);
+ const entry = {
+ id: String(Date.now()),
+ version,
+ note: normalizeVersionString(req.body?.note),
+ createdAt: Date.now(),
+ snapshot: snap,
+ changes
+ };
+ versions.push(entry);
+ audioVersion = version;
+ saveData();
+ log('INFO', `Audio version published: ${version}`);
+ res.json({ success: true, version });
+});
+
+app.get('/api/versions', (req, res) => {
+ const list = versions
+ .slice()
+ .reverse()
+ .map(v => ({
+ id: v.id,
+ version: v.version,
+ note: v.note || '',
+ createdAt: v.createdAt,
+ changes: v.changes || { added: [], modified: [], deleted: [] }
+ }));
+ res.json({ success: true, activeVersion: audioVersion, versions: list });
+});
+
+app.post('/api/versions', (req, res) => {
+ const version = normalizeVersionString(req.body?.version) || String(Date.now());
+ const snap = snapshotFiles();
+ const prev = versions.length > 0 ? versions[versions.length - 1].snapshot : null;
+ const changes = diffSnapshots(prev, snap);
+ const entry = {
+ id: String(Date.now()),
+ version,
+ note: normalizeVersionString(req.body?.note),
+ createdAt: Date.now(),
+ snapshot: snap,
+ changes
+ };
+ versions.push(entry);
+ audioVersion = version;
+ saveData();
+ log('INFO', `Audio version published: ${version}`);
+ res.json({ success: true, version, id: entry.id });
+});
+
+app.put('/api/versions/:id', (req, res) => {
+ const id = req.params.id;
+ const idx = versions.findIndex(v => v.id === id);
+ if (idx < 0) return res.status(404).json({ success: false, message: 'Version not found' });
+
+ const wasActive = versions[idx].version === audioVersion;
+ const nextVersion = normalizeVersionString(req.body?.version);
+ if (nextVersion) versions[idx].version = nextVersion;
+ const nextNote = normalizeVersionString(req.body?.note);
+ if (req.body?.note !== undefined) versions[idx].note = nextNote;
+
+ if (wasActive) audioVersion = versions[idx].version;
+
+ saveData();
+ res.json({ success: true });
+});
+
+app.get('/api/versions/:id/changes', (req, res) => {
+ const id = req.params.id;
+ const entry = versions.find(v => v.id === id);
+ if (!entry) return res.status(404).json({ success: false, message: 'Version not found' });
+ res.json({ success: true, changes: entry.changes || { added: [], modified: [], deleted: [] } });
+});
+
+app.get('/api/versions/:id/files', (req, res) => {
+ const id = req.params.id;
+ const entry = versions.find(v => v.id === id);
+ if (!entry) return res.status(404).json({ success: false, message: 'Version not found' });
+ res.json({ success: true, files: entry.snapshot || {} });
+});
+
+app.post('/api/save-data', (req, res) => {
+ saveData();
+ log('INFO', 'Manual data save triggered');
+ res.json({ success: true });
+});
+
+app.post('/api/upload/list', upload.single('listFile'), (req, res) => {
+ log('SUCCESS', 'Updated list.txt');
+ res.json({ success: true, message: 'list.txt updated' });
+});
+
+app.post('/api/upload/general-audio', upload.array('generalAudioFiles'), (req, res) => {
+ const files = req.files || [];
+ log('SUCCESS', `Uploaded ${files.length} general audio files`);
+ res.json({ success: true, message: `${files.length} general audio files uploaded` });
+});
+
+app.post('/api/upload/station-audio', upload.array('stationAudioFiles'), (req, res) => {
+ const files = req.files || [];
+ log('SUCCESS', `Uploaded ${files.length} station audio files`);
+ res.json({ success: true, message: `${files.length} station audio files uploaded` });
+});
+
+app.get('/api/files', (req, res) => {
+ res.json({ files: getManagedFiles() });
+});
+
+app.delete('/api/files', (req, res) => {
+ const filePath = req.query.path;
+ if (!filePath) return res.status(400).json({ success: false, message: 'Path required' });
+
+ // Security check: prevent directory traversal
+ const safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '');
+ const fullPath = path.join(__dirname, 'public', safePath);
+
+ if (!fullPath.startsWith(uploadDir)) {
+ return res.status(403).json({ success: false, message: 'Invalid path' });
+ }
+
+ if (fs.existsSync(fullPath)) {
+ try {
+ fs.unlinkSync(fullPath);
+ log('INFO', `Deleted file: ${safePath}`);
+
+ // Also try to delete counterpart (.dfpwm if .wav deleted, or vice versa)
+ if (safePath.endsWith('.wav')) {
+ const dfpwmPath = fullPath.replace(/\.wav$/, '.dfpwm');
+ if (fs.existsSync(dfpwmPath)) fs.unlinkSync(dfpwmPath);
+ } else if (safePath.endsWith('.dfpwm')) {
+ const wavPath = fullPath.replace(/\.dfpwm$/, '.wav');
+ if (fs.existsSync(wavPath)) fs.unlinkSync(wavPath);
+ }
+
+ res.json({ success: true });
+ } catch (e) {
+ res.status(500).json({ success: false, message: e.message });
+ }
+ } else {
+ res.status(404).json({ success: false, message: 'File not found' });
+ }
+});
+
+app.get('/api/stations', (req, res) => {
+ const list = getMergedStationList()
+ .sort((a, b) => String(a.code || '').localeCompare(String(b.code || '')) || String(a.name || '').localeCompare(String(b.name || '')));
+ res.json({ success: true, stations: list });
+});
+
+app.post('/api/stations', express.json(), (req, res) => {
+ const name = String(req.body?.name || '').trim();
+ const code = req.body?.code === undefined ? '' : String(req.body.code).trim();
+ if (!name) return res.status(400).json({ success: false, message: 'Station name required' });
+ if (stations[name]) return res.status(409).json({ success: false, message: 'Station already exists' });
+
+ stations[name] = {
+ name,
+ code,
+ platform: getPlatformNumberFromName(name),
+ lastSeen: Date.now()
+ };
+ indexStation(stations[name]);
+ saveData();
+ broadcastUpdate();
+ res.json({ success: true, station: { name, code, lastSeen: stations[name].lastSeen } });
+});
+
+app.put('/api/stations/:name', express.json(), (req, res) => {
+ const name = req.params.name;
+ const st = stations[name];
+ if (!st) return res.status(404).json({ success: false, message: 'Station not found' });
+
+ const newName = req.body?.newName === undefined ? '' : String(req.body.newName).trim();
+ const nextCode = req.body?.code === undefined ? undefined : String(req.body.code).trim();
+
+ let targetName = name;
+ if (newName && newName !== name) {
+ if (stations[newName]) return res.status(409).json({ success: false, message: 'New station name already exists' });
+ stations[newName] = { ...st, name: newName };
+ unindexStation(stations[name]);
+ delete stations[name];
+ targetName = newName;
+ }
+
+ if (nextCode !== undefined) stations[targetName].code = nextCode;
+ stations[targetName].platform = getPlatformNumberFromName(stations[targetName].name);
+ stations[targetName].lastSeen = stations[targetName].lastSeen || Date.now();
+ indexStation(stations[targetName]);
+
+ saveData();
+ broadcastUpdate();
+ res.json({ success: true, station: { name: stations[targetName].name, code: stations[targetName].code, lastSeen: stations[targetName].lastSeen } });
+});
+
+app.delete('/api/stations/:name', (req, res) => {
+ const name = req.params.name;
+ if (stations[name]) {
+ unindexStation(stations[name]);
+ delete stations[name];
+ log('INFO', `Deleted station: ${name}`);
+ saveData();
+ broadcastUpdate();
+ res.json({ success: true });
+ } else {
+ res.status(404).json({ success: false, message: 'Station not found' });
+ }
+});
+
+app.delete('/api/streams/:streamName', (req, res) => {
+ const streamName = String(req.params.streamName || '').trim();
+ if (!streamName) return res.status(400).json({ success: false, message: 'Stream name required' });
+ const state = hlsStreams[streamName];
+ if (!state) return res.status(404).json({ success: false, message: 'Stream not found' });
+ if (state.currentProcess) {
+ try { state.currentProcess.kill('SIGKILL'); } catch {}
+ }
+ delete hlsStreams[streamName];
+ res.json({ success: true });
+});
+
+app.delete('/api/trains/:id', (req, res) => {
+ const id = req.params.id;
+ const initialLength = activeTrains.length;
+ activeTrains = activeTrains.filter(t => t.trainId !== id);
+
+ if (activeTrains.length < initialLength) {
+ log('INFO', `Deleted train: ${id}`);
+ saveData();
+ broadcastUpdate();
+ res.json({ success: true });
+ } else {
+ res.status(404).json({ success: false, message: 'Train not found' });
+ }
+});
+
+app.get('/api/files/content', (req, res) => {
+ const filePath = req.query.path;
+ if (!filePath) return res.status(400).json({ success: false, message: 'Path required' });
+
+ const safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '');
+ const fullPath = path.join(__dirname, 'public', safePath);
+
+ if (!fullPath.startsWith(uploadDir)) {
+ return res.status(403).json({ success: false, message: 'Invalid path' });
+ }
+
+ if (fs.existsSync(fullPath)) {
+ try {
+ const content = fs.readFileSync(fullPath, 'utf8');
+ res.json({ success: true, content });
+ } catch (e) {
+ res.status(500).json({ success: false, message: e.message });
+ }
+ } else {
+ res.status(404).json({ success: false, message: 'File not found' });
+ }
+});
+
+app.post('/api/files/content', express.json(), (req, res) => {
+ const { path: filePath, content } = req.body;
+ if (!filePath || content === undefined) return res.status(400).json({ success: false, message: 'Missing parameters' });
+
+ const safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '');
+ const fullPath = path.join(__dirname, 'public', safePath);
+
+ if (!fullPath.startsWith(uploadDir)) {
+ return res.status(403).json({ success: false, message: 'Invalid path' });
+ }
+
+ try {
+ fs.writeFileSync(fullPath, content, 'utf8');
+ log('INFO', `Updated file: ${safePath}`);
+ res.json({ success: true });
+ } catch (e) {
+ res.status(500).json({ success: false, message: e.message });
+ }
+});
+
+// TTS Generation Proxy
+const tls = require('tls');
+
+function requestViaProxy(urlStr, options, proxyUrlStr) {
+ return new Promise((resolve, reject) => {
+ const url = new URL(urlStr);
+ const proxyUrl = new URL(proxyUrlStr);
+
+ const proxyReq = http.request({
+ host: proxyUrl.hostname,
+ port: proxyUrl.port,
+ method: 'CONNECT',
+ path: url.hostname + ':' + (url.port || 443),
+ });
+
+ proxyReq.on('connect', (res, socket, head) => {
+ if (res.statusCode !== 200) {
+ return reject(new Error('Proxy connection failed: ' + res.statusCode));
+ }
+ const tlsSocket = tls.connect({
+ socket: socket,
+ servername: url.hostname
+ }, () => {
+ const req = require('https').request({
+ host: url.hostname,
+ port: url.port || 443,
+ path: url.pathname + url.search,
+ method: options.method || 'GET',
+ headers: options.headers,
+ createConnection: () => tlsSocket
+ }, (res) => {
+ let body = [];
+ res.on('data', chunk => body.push(chunk));
+ res.on('end', () => resolve({
+ ok: res.statusCode >= 200 && res.statusCode < 300,
+ status: res.statusCode,
+ buffer: Buffer.concat(body),
+ text: Buffer.concat(body).toString('utf8')
+ }));
+ });
+
+ req.on('error', reject);
+ if (options.body) req.write(options.body);
+ req.end();
+ });
+ tlsSocket.on('error', reject);
+ });
+
+ proxyReq.on('error', reject);
+ proxyReq.end();
+ });
+}
+
+function requestDirect(urlStr, options) {
+ return new Promise((resolve, reject) => {
+ const url = new URL(urlStr);
+ const req = require('https').request({
+ host: url.hostname,
+ port: url.port || 443,
+ path: url.pathname + url.search,
+ method: options.method || 'GET',
+ headers: options.headers
+ }, (res) => {
+ let body = [];
+ res.on('data', chunk => body.push(chunk));
+ res.on('end', () => resolve({
+ ok: res.statusCode >= 200 && res.statusCode < 300,
+ status: res.statusCode,
+ buffer: Buffer.concat(body),
+ text: Buffer.concat(body).toString('utf8')
+ }));
+ });
+
+ req.on('error', reject);
+ if (options.body) req.write(options.body);
+ req.end();
+ });
+}
+
+async function generateTtsWavToTemp(text, reference_id) {
+ const proxyUrl = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy;
+
+ const requestBody = JSON.stringify({
+ text: text,
+ reference_id: reference_id,
+ format: "wav"
+ });
+
+ const options = {
+ method: 'POST',
+ headers: {
+ 'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649',
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(requestBody)
+ },
+ body: requestBody
+ };
+
+ const response = proxyUrl
+ ? await requestViaProxy('https://api.fish.audio/v1/tts', options, proxyUrl)
+ : await requestDirect('https://api.fish.audio/v1/tts', options);
+
+ if (!response.ok) {
+ const msg = (response.text && String(response.text).trim().length > 0)
+ ? response.text
+ : `Upstream API error (HTTP ${response.status})`;
+ const err = new Error(msg);
+ err.status = response.status || 500;
+ throw err;
+ }
+
+ const tempName = `temp_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
+ const tempPath = path.join(uploadDir, tempName);
+ fs.writeFileSync(tempPath, response.buffer);
+
+ return { tempName, tempPath, url: `/uploads/${tempName}` };
+}
+
+app.post('/api/tts/generate', express.json(), async (req, res) => {
+ const { text, reference_id } = req.body;
+ if (!text || !reference_id) return res.status(400).json({ success: false, message: 'Missing text or reference_id' });
+
+ try {
+ const proxyUrl = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy;
+ console.log(`[TTS] Sending request for text: "${text}"${proxyUrl ? ` via proxy: ${proxyUrl}` : ''}`);
+
+ const requestBody = JSON.stringify({
+ text: text,
+ reference_id: reference_id,
+ format: "wav"
+ });
+
+ const options = {
+ method: 'POST',
+ headers: {
+ 'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649',
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(requestBody)
+ },
+ body: requestBody
+ };
+
+ const response = proxyUrl
+ ? await requestViaProxy('https://api.fish.audio/v1/tts', options, proxyUrl)
+ : await requestDirect('https://api.fish.audio/v1/tts', options);
+
+ if (!response.ok) {
+ const msg = (response.text && String(response.text).trim().length > 0)
+ ? response.text
+ : `Upstream API error (HTTP ${response.status})`;
+ console.error(`[TTS] API Error Status ${response.status}: ${msg}`);
+ return res.status(response.status || 500).json({ success: false, message: msg });
+ }
+
+ // Save to a temporary file
+ const tempName = `temp_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
+ const tempPath = path.join(uploadDir, tempName);
+ fs.writeFileSync(tempPath, response.buffer);
+
+ console.log(`[TTS] Success: saved to ${tempName}`);
+ res.json({ success: true, url: `/uploads/${tempName}`, tempName });
+ } catch (e) {
+ console.error("[TTS] Generation Error:", e);
+ const msg = (e && e.message && String(e.message).trim().length > 0)
+ ? e.message
+ : (e && e.code ? String(e.code) : 'Unknown error');
+ res.status(500).json({ success: false, message: msg + (e && e.cause && e.cause.message ? ` (${e.cause.message})` : '') });
+ }
+});
+
+// Endpoint to receive temp files from client
+app.post('/api/tts/upload_temp', upload.fields([{ name: 'zhAudio', maxCount: 1 }, { name: 'enAudio', maxCount: 1 }]), (req, res) => {
+ try {
+ const zhFile = req.files['zhAudio'] ? req.files['zhAudio'][0] : null;
+ const enFile = req.files['enAudio'] ? req.files['enAudio'][0] : null;
+
+ if (!zhFile || !enFile) {
+ return res.status(400).json({ success: false, message: 'Missing audio files' });
+ }
+
+ // Rename to secure temp names
+ const zhTempName = `temp_zh_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
+ const enTempName = `temp_en_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
+
+ const zhTempPath = path.join(uploadDir, zhTempName);
+ const enTempPath = path.join(uploadDir, enTempName);
+
+ fs.renameSync(zhFile.path, zhTempPath);
+ fs.renameSync(enFile.path, enTempPath);
+
+ res.json({
+ success: true,
+ zhTempName,
+ enTempName,
+ zhUrl: `/uploads/${zhTempName}`,
+ enUrl: `/uploads/${enTempName}`
+ });
+ } catch (e) {
+ res.status(500).json({ success: false, message: e.message });
+ }
+});
+
+// Import TTS temp files to station folder
+app.post('/api/tts/import', express.json(), (req, res) => {
+ const { stationCode, zhTemp, enTemp } = req.body;
+ if (!stationCode || !zhTemp || !enTemp) return res.status(400).json({ success: false, message: 'Missing parameters' });
+
+ const zhSource = path.join(uploadDir, zhTemp);
+ const enSource = path.join(uploadDir, enTemp);
+ const safeStationCode = String(stationCode).replace(/\//g, '-');
+ const zhDest = path.join(audioStationDir, `ch-${safeStationCode}.wav`);
+ const enDest = path.join(audioStationDir, `en-${safeStationCode}.wav`);
+
+ try {
+ if (fs.existsSync(zhSource)) {
+ fs.copyFileSync(zhSource, zhDest);
+ fs.unlinkSync(zhSource);
+ }
+ if (fs.existsSync(enSource)) {
+ fs.copyFileSync(enSource, enDest);
+ fs.unlinkSync(enSource);
+ }
+ log('SUCCESS', `Imported generated audio for station ${stationCode}`);
+ res.json({ success: true });
+ } catch (e) {
+ log('ERROR', `Failed to import audio: ${e.message}`);
+ res.status(500).json({ success: false, message: e.message });
+ }
+});
+
+function safeFilename(name) {
+ const base = path.basename(String(name || '').trim());
+ if (!base || base.includes('/') || base.includes('\\')) return null;
+ return base;
+}
+
+function findWavByBaseName(baseName) {
+ const safeBase = String(baseName || '').trim().replace(/\.wav$/i, '');
+ if (!safeBase) return null;
+ const candidates = [
+ path.join(audioGeneralDir, `${safeBase}.wav`),
+ path.join(audioStationDir, `${safeBase}.wav`),
+ path.join(uploadDir, `${safeBase}.wav`)
+ ];
+ return candidates.find(p => fs.existsSync(p)) || null;
+}
+
+function buildTempBroadcastFromWav(prefixWavPath, contentWavPath) {
+ const broadcastId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ const outBase = `temp_${broadcastId}`;
+ const outWavPath = path.join(tempBroadcastDir, `${outBase}.wav`);
+
+ const filterComplex = [
+ '[0:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a0]',
+ '[1:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a1]',
+ '[a0][a1]concat=n=2:v=0:a=1[a]'
+ ].join(';');
+
+ execSync(`ffmpeg -hide_banner -loglevel error -y -i "${prefixWavPath}" -i "${contentWavPath}" -filter_complex "${filterComplex}" -map "[a]" -ac 1 -ar 48000 -c:a pcm_s16le "${outWavPath}"`);
+
+ convertToDfpwm(outWavPath);
+
+ const dfpwmPath = outWavPath.replace(/\.wav$/i, '.dfpwm');
+ if (!fs.existsSync(dfpwmPath)) throw new Error('DFPWM build failed');
+ const dfpwmBuf = fs.readFileSync(dfpwmPath);
+ const durationSec = dfpwmBuf.length / 6000;
+
+ return {
+ broadcastId,
+ wavPath: outWavPath,
+ dfpwmPath,
+ durationSec
+ };
+}
+
+app.post('/api/temp-broadcast/build', (req, res) => {
+ const contentTempName = safeFilename(req.body?.contentTempName);
+ const prefixName = String(req.body?.prefixName || 'lsts').trim();
+ if (!contentTempName) return res.status(400).json({ success: false, message: 'Invalid contentTempName' });
+
+ const contentPath = path.join(uploadDir, contentTempName);
+ if (!contentPath.startsWith(uploadDir)) return res.status(403).json({ success: false, message: 'Invalid path' });
+ if (!fs.existsSync(contentPath)) return res.status(404).json({ success: false, message: 'Content temp wav not found' });
+
+ const prefixWavPath = findWavByBaseName(prefixName);
+ if (!prefixWavPath) return res.status(404).json({ success: false, message: `Prefix wav not found: ${prefixName}.wav` });
+
+ try {
+ const built = buildTempBroadcastFromWav(prefixWavPath, contentPath);
+ res.json({
+ success: true,
+ broadcastId: built.broadcastId,
+ wavUrl: `/uploads/temp-broadcast/${path.basename(built.wavPath)}`,
+ durationSec: built.durationSec
+ });
+ } catch (e) {
+ res.status(500).json({ success: false, message: e?.message || String(e) });
+ }
+});
+
+app.post('/api/temp-broadcast/compose', express.json(), async (req, res) => {
+ const text = String(req.body?.text || '').trim();
+ const reference_id = String(req.body?.reference_id || DEFAULT_ZH_MODEL).trim();
+ const prefixName = String(req.body?.prefixName || 'lsts').trim();
+ if (!text) return res.status(400).json({ success: false, message: 'text required' });
+ if (!reference_id) return res.status(400).json({ success: false, message: 'reference_id required' });
+
+ const prefixWavPath = findWavByBaseName(prefixName);
+ if (!prefixWavPath) return res.status(404).json({ success: false, message: `Prefix wav not found: ${prefixName}.wav` });
+
+ let ttsTempPath = null;
+ try {
+ const tts = await generateTtsWavToTemp(text, reference_id);
+ ttsTempPath = tts.tempPath;
+ const built = buildTempBroadcastFromWav(prefixWavPath, ttsTempPath);
+ try { fs.unlinkSync(ttsTempPath); } catch {}
+ ttsTempPath = null;
+
+ res.json({
+ success: true,
+ broadcastId: built.broadcastId,
+ wavUrl: `/uploads/temp-broadcast/${path.basename(built.wavPath)}`,
+ durationSec: built.durationSec
+ });
+ } catch (e) {
+ if (ttsTempPath) {
+ try { fs.unlinkSync(ttsTempPath); } catch {}
+ }
+ res.status(Number(e?.status) || 500).json({ success: false, message: e?.message || String(e) });
+ }
+});
+
+app.post('/api/temp-broadcast/publish', (req, res) => {
+ const broadcastId = String(req.body?.broadcastId || '').trim();
+ const urgency = String(req.body?.urgency || 'normal').trim().toLowerCase();
+ const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : [];
+
+ if (!broadcastId) return res.status(400).json({ success: false, message: 'broadcastId required' });
+ if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' });
+ if (urgency !== 'normal' && urgency !== 'emergency') return res.status(400).json({ success: false, message: 'Invalid urgency' });
+
+ const wavPath = path.join(tempBroadcastDir, `temp_${broadcastId}.wav`);
+ const dfpwmPath = wavPath.replace(/\.wav$/i, '.dfpwm');
+ if (!fs.existsSync(dfpwmPath)) return res.status(404).json({ success: false, message: 'Temp broadcast not found (dfpwm)' });
+
+ const dfpwmBuf = fs.readFileSync(dfpwmPath);
+ const priority = urgency === 'emergency' ? 2 : 0;
+ const interruptMode = urgency === 'emergency' ? 'all' : 'none';
+ const source = urgency === 'emergency' ? 'temp_emergency' : 'temp_normal';
+ const requeueOnPreempt = urgency !== 'emergency';
+
+ let sent = 0;
+ let failed = 0;
+ const results = [];
+
+ for (const rawName of stationsReq) {
+ const name = String(rawName || '').trim();
+ if (!name) continue;
+ const ws = getStationWs(name);
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ failed++;
+ results.push({ stationName: name, ok: false, reason: 'offline' });
+ continue;
+ }
+ enqueueStationAudioTask(name, ws, {
+ buffer: dfpwmBuf,
+ priority,
+ interruptMode,
+ source,
+ audioId: `temp-${broadcastId}-${name}-${Date.now()}`,
+ requeueOnPreempt,
+ consoleStreamStart: { type: 'stream_start', station: name, seq: [`TEMP:${broadcastId}`], streamName: getStationStreamName(name), hlsUrl: getStationHlsUrl(getStationStreamName(name)) },
+ consoleStreamEnd: { type: 'stream_end', station: name }
+ });
+ sent++;
+ results.push({ stationName: name, ok: true });
+ }
+
+ res.json({ success: true, sent, failed, results });
+});
+
+app.get('/api/music/tracks', (req, res) => {
+ res.json({ success: true, tracks: listMusicTracks() });
+});
+
+app.post('/api/music/start', express.json(), (req, res) => {
+ const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : [];
+ const track = normalizeBaseName(req.body?.track);
+ const loop = Boolean(req.body?.loop);
+
+ if (!track) return res.status(400).json({ success: false, message: 'track required' });
+ if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' });
+
+ let dfpwmBuf;
+ try {
+ dfpwmBuf = getDfpwmBufferByBaseName(track);
+ } catch (e) {
+ return res.status(404).json({ success: false, message: e?.message || String(e) });
+ }
+
+ let sent = 0;
+ let failed = 0;
+ const results = [];
+
+ for (const rawName of stationsReq) {
+ const name = String(rawName || '').trim();
+ if (!name) continue;
+ const ws = getStationWs(name);
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ failed++;
+ results.push({ stationName: name, ok: false, reason: 'offline' });
+ continue;
+ }
+
+ stopStationMusic(name, ws);
+ stationMusicStates.set(name, { enabled: true, track, loop });
+
+ const enqueueOnce = () => {
+ const currentWs = getStationWs(name);
+ if (!currentWs || currentWs.readyState !== WebSocket.OPEN) {
+ // If offline, retry later instead of giving up
+ setTimeout(enqueueOnce, 5000);
+ return;
+ }
+
+ enqueueStationAudioTask(name, currentWs, {
+ buffer: dfpwmBuf,
+ priority: -1,
+ interruptMode: 'none',
+ source: 'music',
+ audioId: `music-${track}-${name}-${Date.now()}`,
+ requeueOnPreempt: true,
+ consoleStreamStart: { type: 'stream_start', station: name, seq: [`MUSIC:${track}`], streamName: getStationStreamName(name), hlsUrl: getStationHlsUrl(getStationStreamName(name)) },
+ consoleStreamEnd: { type: 'stream_end', station: name },
+ onDone: (info) => {
+ const st = stationMusicStates.get(name);
+ if (!st || !st.enabled) return;
+ if (st.track !== track) return;
+ if (!st.loop) return;
+ // Only stop if cancelled by something else (like stopMusic or higher priority)
+ if (info && info.cancelled) {
+ // If it was just preempted, enqueueStationAudioTask handles the re-insertion
+ // If it was cancelled by stopStationMusic, st.enabled will be false
+ return;
+ }
+ enqueueOnce();
+ }
+ });
+ };
+
+ enqueueOnce();
+ sent++;
+ results.push({ stationName: name, ok: true });
+ }
+
+ res.json({ success: true, sent, failed, results });
+});
+
+app.post('/api/music/stop', express.json(), (req, res) => {
+ const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : [];
+ if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' });
+
+ let stopped = 0;
+ const results = [];
+
+ for (const rawName of stationsReq) {
+ const name = String(rawName || '').trim();
+ if (!name) continue;
+ const ws = getStationWs(name);
+ stationMusicStates.set(name, { enabled: false, track: null, loop: false });
+ stopStationMusic(name, ws);
+ stopped++;
+ results.push({ stationName: name, ok: true });
+ }
+
+ res.json({ success: true, stopped, results });
+});
+
+// Error handling middleware
+app.use((err, req, res, next) => {
+ log('ERROR', `Unhandled error: ${err.message}`);
+ res.status(err.status || 500).json({
+ success: false,
+ message: err.message || 'Internal Server Error'
+ });
+});
+
+server.listen(PORT, () => {
+ loadData();
+ startCtmClient();
+ if (!audioVersion) audioVersion = String(Date.now());
+ if (!Array.isArray(versions) || versions.length === 0) {
+ const snap = snapshotFiles();
+ const changes = diffSnapshots(null, snap);
+ versions = [{
+ id: String(Date.now()),
+ version: audioVersion,
+ note: '',
+ createdAt: Date.now(),
+ snapshot: snap,
+ changes
+ }];
+ saveData();
+ }
+ console.log(`Cloud Server running on port ${PORT}`);
+});
diff --git a/aukit.lua b/aukit.lua
new file mode 100644
index 0000000..439c2b4
--- /dev/null
+++ b/aukit.lua
@@ -0,0 +1,3620 @@
+--- AUKit: aukit.Audio decoding and processing framework for ComputerCraft
+--- @file
+---
+--- AUKit is a framework designed to simplify the process of loading, modifying,
+--- and playing audio files in various formats. It includes support for loading
+--- audio from many sources, including PCM, DFPWM, G.711, and ADPCM codecs, as
+--- well as WAV, AIFF, AU, and FLAC files. It can also generate audio on-the-fly
+--- as tones, noise, or silence.
+---
+--- AUKit uses a structure called aukit.Audio to store information about each audio
+--- chunk. An audio object holds the sample rate of the audio, as well as the
+--- data for each channel stored as floating-point numbers. aukit.Audio objects can
+--- hold any number of channels at any sample rate with any duration.
+---
+--- To obtain an audio object, you can use any of the main functions in the aukit
+--- module. These allow loading from various raw codecs or file formats, with
+--- data sources as strings, or tables if using a raw codec loader.
+---
+--- Once the audio is loaded, various basic operations are available. A subset of
+--- the string library is available to simplify operations on the audio, and a
+--- number of operators (+, *, .., #) are overridden as well. There's also built-
+--- in functions for resampling the audio, with nearest-neighbor, linear, cubic,
+--- and sinc interpolation available; as well as mixing channels (including down to
+--- mono) and combining/splitting channels. Finally, audio objects can be exported
+--- back to PCM, DFPWM, or WAV data, allowing changes to be easily stored on disk.
+--- The stream function also automatically chunks data for use with a speaker.
+--- All of these functions return a new audio object, leaving the original intact.
+---
+--- There are also a number of effects available for audio. These are contained
+--- in the aukit.effects table, and modify the audio passed to them (as well as
+--- returning the audio for streamlining). The effects are intended to speed up
+--- common operations on audio. More effects may be added in future versions.
+---
+--- For simple audio playback tasks, the aukit.stream table provides a number of
+--- functions that can quickly decode audio for real-time playback. Each function
+--- returns an iterator function that can be called multiple times to obtain fully
+--- decoded chunks of audio in 8-bit PCM, ready for playback to one or more
+--- speakers. The functions decode the data, resample it to 48 kHz (using the
+--- default resampling method), apply a low-pass filter to decrease interpolation
+--- error, mix to mono if desired, and then return a list of tables with samples
+--- in the range [-128, 127], plus the current position of the audio. The
+--- iterators can be passed directly to the aukit.play function, which complements
+--- the aukit.stream suite by playing the decoded audio on speakers while decoding
+--- it in real-time, handling synchronization of speakers as best as possible.
+---
+--- If you're really lazy, you can also call `aukit` as a function, which takes
+--- the path to a file, and plays this on all available speakers.
+---
+--- Be aware that processing large amounts of audio (especially loading FLAC or
+--- resampling with higher quality) is *very* slow. It's recommended to use audio
+--- files with lower data size (8-bit mono PCM/WAV/AIFF is ideal), and potentially
+--- a lower sample rate, to reduce the load on the system - especially as all
+--- data gets converted to 8-bit DFPWM data on playback anyway. The code yields
+--- internally when things take a long time to avoid abort timeouts.
+---
+--- For an example of how to use AUKit, see the accompanying auplay.lua file.
+---
+--- @author JackMacWindows
+---
+--- @copyright
+---
+--- MIT License
+---
+--- Copyright (c) 2021-2024 JackMacWindows
+---
+--- Permission is hereby granted, free of charge, to any person obtaining a copy
+--- of this software and associated documentation files (the "Software"), to deal
+--- in the Software without restriction, including without limitation the rights
+--- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+--- copies of the Software, and to permit persons to whom the Software is
+--- furnished to do so, subject to the following conditions:
+---
+--- The above copyright notice and this permission notice shall be included in all
+--- copies or substantial portions of the Software.
+---
+--- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+--- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+--- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+--- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+--- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+--- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+--- SOFTWARE.
+
+local expect = require "cc.expect"
+local dfpwm = require "cc.audio.dfpwm"
+
+local bit32_band, bit32_bxor, bit32_lshift, bit32_rshift, bit32_arshift, bit32_btest, bit32_extract = bit32.band, bit32.bxor, bit32.lshift, bit32.rshift, bit32.arshift, bit32.btest, bit32.extract
+local math_floor, math_ceil, math_sin, math_abs, math_fmod, math_min, math_max, math_pi = math.floor, math.ceil, math.sin, math.abs, math.fmod, math.min, math.max, math.pi
+local os_epoch, os_queueEvent, os_pullEvent = os.epoch, os.queueEvent, os.pullEvent
+local str_pack, str_unpack, str_sub, str_byte, str_rep = string.pack, string.unpack, string.sub, string.byte, string.rep
+local table_pack, table_unpack, table_insert, table_remove = table.pack, table.unpack, table.insert, table.remove
+
+--- The main AUKit module.
+---@class aukit
+---@field _VERSION string The version of AUKit that is loaded. This follows [SemVer](https://semver.org) format.
+---@field defaultInterpolation "none"|"linear"|"cubic"|"sinc" Default interpolation mode for `Audio.resample` and other functions that need to resample.
+local aukit = setmetatable({
+ _VERSION = "1.10.0",
+ defaultInterpolation = "linear"
+}, {__call = function(aukit, path)
+ expect(1, path, "string")
+ local file = assert(fs.open(path, "rb"))
+ local type = aukit.detect(file.read(64)) or "dfpwm"
+ file.seek("set", 0)
+ aukit.play(aukit.stream[type](function() return file.read(48000) end), peripheral.find("speaker"))
+ file.close()
+end})
+--- Effects that can modify audio chunks.
+---@class aukit.effects
+aukit.effects = {}
+--- Loader functions for streaming audio from a remote resource. These are usually used with `aukit.play`.
+---@class aukit.stream
+aukit.stream = {}
+
+
+--- The aukit.Audio class represents a chunk of audio with variable channels and sample rate.
+---@class aukit.Audio
+---@field data number[][] The samples in each channel.
+---@field sampleRate number The sample rate of the audio.
+---@field metadata table Stores any metadata read from the file if present.
+---@field info Metadata Stores any decoder-specific information, including `bitDepth` and `dataType`.
+local Audio = {}
+local Audio_mt
+
+local dfpwmUUID = "3ac1fa38-811d-4361-a40d-ce53ca607cd1" -- UUID for DFPWM in WAV files
+
+local function uuidBytes(uuid) return uuid:gsub("-", ""):gsub("%x%x", function(c) return string.char(tonumber(c, 16)) end) end
+
+local sincWindowSize = jit and 30 or 10
+
+local wavExtensible = {
+ dfpwm = uuidBytes(dfpwmUUID),
+ pcm = uuidBytes "01000000-0000-1000-8000-00aa00389b71",
+ msadpcm = uuidBytes "02000000-0000-1000-8000-00aa00389b71",
+ alaw = uuidBytes "06000000-0000-1000-8000-00aa00389b71",
+ ulaw = uuidBytes "07000000-0000-1000-8000-00aa00389b71",
+ adpcm = uuidBytes "11000000-0000-1000-8000-00aa00389b71",
+ pcm_float = uuidBytes "03000000-0000-1000-8000-00aa00389b71"
+}
+
+local wavExtensibleChannels = {
+ 0x04,
+ 0x03,
+ 0x07,
+ 0x33,
+ 0x37,
+ 0x3F,
+ 0x637,
+ 0x63F,
+ 0x50F7,
+ 0x50FF,
+ 0x56F7,
+ 0x56FF
+}
+
+local ima_index_table = {
+ [0] = -1, -1, -1, -1, 2, 4, 6, 8,
+ -1, -1, -1, -1, 2, 4, 6, 8
+}
+
+local ima_step_table = {
+ [0] = 7, 8, 9, 10, 11, 12, 13, 14, 16, 17,
+ 19, 21, 23, 25, 28, 31, 34, 37, 41, 45,
+ 50, 55, 60, 66, 73, 80, 88, 97, 107, 118,
+ 130, 143, 157, 173, 190, 209, 230, 253, 279, 307,
+ 337, 371, 408, 449, 494, 544, 598, 658, 724, 796,
+ 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066,
+ 2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358,
+ 5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899,
+ 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767
+}
+
+local msadpcm_adaption_table = {
+ [0] = 230, 230, 230, 230, 307, 409, 512, 614,
+ [-8] = 768, [-7] = 614, [-6] = 512, [-5] = 409, [-4] = 307, [-3] = 230, [-2] = 230, [-1] = 230
+}
+
+local flacMetadata = {
+ tracknumber = "trackNumber",
+ ["encoded-by"] = "encodedBy",
+ sourcemedia = "sourceMedia",
+ labelno = "labelNumber",
+ discnumber = "discNumber",
+ partnumber = "partNumber",
+ productnumber = "productNumber",
+ catalognumber = "catalogNumber",
+ ["release date"] = "releaseDate",
+ ["source medium"] = "sourceMedium",
+ ["source artist"] = "sourceArtist",
+ ["guest artist"] = "guestArtist",
+ ["source work"] = "sourceWork",
+ disctotal = "discCount",
+ tracktotal = "trackCount",
+ parttotal = "partCount",
+ tcm = "composer"
+}
+
+local wavMetadata = {
+ IPRD = "album",
+ INAM = "title",
+ IART = "artist",
+ IWRI = "author",
+ IMUS = "composer",
+ IPRO = "producer",
+ IPRT = "trackNumber",
+ ITRK = "trackNumber",
+ IFRM = "trackCount",
+ PRT1 = "partNumber",
+ PRT2 = "partCount",
+ TLEN = "length",
+ IRTD = "rating",
+ ICRD = "date",
+ ITCH = "encodedBy",
+ ISFT = "encoder",
+ ISRF = "media",
+ IGNR = "genre",
+ ICMT = "comment",
+ ICOP = "copyright",
+ ILNG = "language"
+}
+
+local function utf8decode(str, pos)
+ local codes = {utf8.codepoint(str, 1, -1)}
+ for i, v in ipairs(codes) do if v > 0xFF then codes[i] = 0x3F end end
+ return string.char(table_unpack(codes)), pos
+end
+
+local function clamp(n, min, max)
+ if n < min then return min
+ elseif n > max then return max
+ else return n end
+end
+
+local function expectAudio(n, var)
+ if type(var) == "table" and getmetatable(var) == Audio_mt then return var end
+ expect(n, var, "Audio") -- always fails
+end
+
+local function copy(tab)
+ local t = {}
+ for k, v in pairs(tab) do t[k] = v end
+ return t
+end
+
+local function intunpack(str, pos, sz, signed, be)
+ local n = 0
+ if be then for i = 0, sz - 1 do n = n * 256 + str_byte(str, pos+i) end
+ else for i = 0, sz - 1 do n = n + str_byte(str, pos+i) * 2^(8*i) end end
+ if signed and n >= 2^(sz*8-1) then n = n - 2^(sz*8) end
+ return n, pos + sz
+end
+
+local interpolate = {
+ none = function(data, x)
+ return data[math_floor(x)]
+ end,
+ linear = function(data, x)
+ local ffx = math_floor(x)
+ return data[ffx] + ((data[ffx+1] or data[ffx]) - data[ffx]) * (x - ffx)
+ end,
+ cubic = function(data, x)
+ local ffx = math_floor(x)
+ local p0, p1, p2, p3, fx = data[ffx-1], data[ffx], data[ffx+1], data[ffx+2], x - ffx
+ p0, p2, p3 = p0 or p1, p2 or p1, p3 or p2 or p1
+ return (-0.5*p0 + 1.5*p1 - 1.5*p2 + 0.5*p3)*fx^3 + (p0 - 2.5*p1 + 2*p2 - 0.5*p3)*fx^2 + (-0.5*p0 + 0.5*p2)*fx + p1
+ end,
+ sinc = function(data, x)
+ local ffx = math_floor(x)
+ local fx = x - ffx
+ local sum = 0
+ for n = -sincWindowSize, sincWindowSize do
+ local idx = ffx+n
+ local d = data[idx]
+ if d then
+ local px = math_pi * (fx - n)
+ if px == 0 then sum = sum + d
+ else sum = sum + d * math_sin(px) / px end
+ end
+ end
+ return sum
+ end
+}
+local interpolation_start = {none = 1, linear = 1, cubic = 0, sinc = 0}
+local interpolation_end = {none = 1, linear = 2, cubic = 3, sinc = 0}
+
+local wavegen = {
+ sine = function(x, freq, amplitude)
+ return math_sin(2 * x * math_pi * freq) * amplitude
+ end,
+ triangle = function(x, freq, amplitude)
+ return 2.0 * math_abs(amplitude * math_fmod(2.0 * x * freq + 1.5, 2.0) - amplitude) - amplitude
+ end,
+ square = function(x, freq, amplitude, duty)
+ if (x * freq) % 1 >= duty then return -amplitude else return amplitude end
+ end,
+ sawtooth = function(x, freq, amplitude)
+ return amplitude * math_fmod(2.0 * x * freq + 1.0, 2.0) - amplitude
+ end
+}
+
+--[[
+.########.##..........###.....######.
+.##.......##.........##.##...##....##
+.##.......##........##...##..##......
+.######...##.......##.....##.##......
+.##.......##.......#########.##......
+.##.......##.......##.....##.##....##
+.##.......########.##.....##..######.
+]]
+
+local decodeFLAC do
+
+ -- Simple FLAC decoder (Java)
+ --
+ -- Copyright (c) 2017 Project Nayuki. (MIT License)
+ -- https://www.nayuki.io/page/simple-flac-implementation
+ --
+ -- Permission is hereby granted, free of charge, to any person obtaining a copy of
+ -- this software and associated documentation files (the "Software"), to deal in
+ -- the Software without restriction, including without limitation the rights to
+ -- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+ -- the Software, and to permit persons to whom the Software is furnished to do so,
+ -- subject to the following conditions:
+ -- - The above copyright notice and this permission notice shall be included in
+ -- all copies or substantial portions of the Software.
+ -- - The Software is provided "as is", without warranty of any kind, express or
+ -- implied, including but not limited to the warranties of merchantability,
+ -- fitness for a particular purpose and noninfringement. In no event shall the
+ -- authors or copyright holders be liable for any claim, damages or other
+ -- liability, whether in an action of contract, tort or otherwise, arising from,
+ -- out of or in connection with the Software or the use or other dealings in the
+ -- Software.
+
+ local FIXED_PREDICTION_COEFFICIENTS = {
+ {},
+ {1},
+ {2, -1},
+ {3, -3, 1},
+ {4, -6, 4, -1},
+ };
+
+ local function BitInputStream(data, pos)
+ local obj = {}
+ local bitBuffer, bitBufferLen = 0, 0
+ function obj.alignToByte()
+ bitBufferLen = bitBufferLen - bitBufferLen % 8
+ end
+ function obj.readByte()
+ return obj.readUint(8)
+ end
+ function obj.readUint(n)
+ if n == 0 then return 0 end
+ while bitBufferLen < n do
+ local temp = str_byte(data, pos)
+ pos = pos + 1
+ if temp == nil then return nil end
+ bitBuffer = (bitBuffer * 256 + temp) % 0x100000000000
+ bitBufferLen = bitBufferLen + 8
+ end
+ bitBufferLen = bitBufferLen - n
+ local result = math_floor(bitBuffer / 2^bitBufferLen)
+ if n < 32 then result = result % 2^n end
+ return result
+ end
+ function obj.readSignedInt(n)
+ local v = obj.readUint(n)
+ if v >= 2^(n-1) then v = v - 2^n end
+ return v
+ end
+ function obj.readRiceSignedInt(param)
+ local val = 0
+ while (obj.readUint(1) == 0) do val = val + 1 end
+ val = val * 2^param + obj.readUint(param)
+ if bit32_btest(val, 1) then return -math_floor(val / 2) - 1
+ else return math_floor(val / 2) end
+ end
+ return obj
+ end
+
+ local function decodeResiduals(inp, warmup, blockSize, result)
+ local method = inp.readUint(2);
+ if (method >= 2) then error("Reserved residual coding method " .. method) end
+ local paramBits = method == 0 and 4 or 5;
+ local escapeParam = method == 0 and 0xF or 0x1F;
+
+ local partitionOrder = inp.readUint(4);
+ local numPartitions = 2^partitionOrder;
+ if (blockSize % numPartitions ~= 0) then
+ error("Block size not divisible by number of Rice partitions")
+ end
+ local partitionSize = math_floor(blockSize / numPartitions);
+
+ for i = 0, numPartitions-1 do
+ local start = i * partitionSize + (i == 0 and warmup or 0);
+ local endd = (i + 1) * partitionSize;
+
+ local param = inp.readUint(paramBits);
+ if (param < escapeParam) then
+ for j = start, endd - 1 do
+ result[j+1] = inp.readRiceSignedInt(param)
+ end
+ else
+ local numBits = inp.readUint(5);
+ for j = start, endd - 1 do
+ result[j+1] = inp.readSignedInt(numBits)
+ end
+ end
+ end
+ end
+
+ local function restoreLinearPrediction(result, coefs, shift, blockSize)
+ for i = #coefs, blockSize - 1 do
+ local sum = 0
+ for j = 0, #coefs - 1 do
+ sum = sum + result[i - j] * coefs[j + 1]
+ end
+ result[i + 1] = result[i + 1] + math_floor(sum / 2^shift)
+ end
+ end
+
+ local function decodeFixedPredictionSubframe(inp, predOrder, sampleDepth, blockSize, result)
+ for i = 1, predOrder do
+ result[i] = inp.readSignedInt(sampleDepth);
+ end
+ decodeResiduals(inp, predOrder, blockSize, result);
+ restoreLinearPrediction(result, FIXED_PREDICTION_COEFFICIENTS[predOrder+1], 0, blockSize);
+ end
+
+ local function decodeLinearPredictiveCodingSubframe(inp, lpcOrder, sampleDepth, blockSize, result)
+ for i = 1, lpcOrder do
+ result[i] = inp.readSignedInt(sampleDepth);
+ end
+ local precision = inp.readUint(4) + 1;
+ local shift = inp.readSignedInt(5);
+ local coefs = {};
+ for i = 1, lpcOrder do
+ coefs[i] = inp.readSignedInt(precision);
+ end
+ decodeResiduals(inp, lpcOrder, blockSize, result);
+ restoreLinearPrediction(result, coefs, shift, blockSize);
+ end
+
+ local function decodeSubframe(inp, sampleDepth, blockSize, result)
+ inp.readUint(1);
+ local type = inp.readUint(6);
+ local shift = inp.readUint(1);
+ if (shift == 1) then
+ while (inp.readUint(1) == 0) do shift = shift + 1 end
+ end
+ sampleDepth = sampleDepth - shift
+
+ if (type == 0) then -- Constant coding
+ local c = inp.readSignedInt(sampleDepth)
+ for i = 1, blockSize do result[i] = c end
+ elseif (type == 1) then -- Verbatim coding
+ for i = 1, blockSize do
+ result[i] = inp.readSignedInt(sampleDepth);
+ end
+ elseif (8 <= type and type <= 12) then
+ decodeFixedPredictionSubframe(inp, type - 8, sampleDepth, blockSize, result)
+ elseif (32 <= type and type <= 63) then
+ decodeLinearPredictiveCodingSubframe(inp, type - 31, sampleDepth, blockSize, result)
+ else
+ error("Reserved subframe type")
+ end
+
+ for i = 1, blockSize do
+ result[i] = result[i] * 2^shift
+ end
+ end
+
+ local function decodeSubframes(inp, sampleDepth, chanAsgn, blockSize, result)
+ local subframes = {}
+ for i = 1, #result do subframes[i] = {} end
+ if (0 <= chanAsgn and chanAsgn <= 7) then
+ for ch = 1, #result do
+ decodeSubframe(inp, sampleDepth, blockSize, subframes[ch])
+ end
+ elseif (8 <= chanAsgn and chanAsgn <= 10) then
+ decodeSubframe(inp, sampleDepth + (chanAsgn == 9 and 1 or 0), blockSize, subframes[1])
+ decodeSubframe(inp, sampleDepth + (chanAsgn == 9 and 0 or 1), blockSize, subframes[2])
+ if (chanAsgn == 8) then
+ for i = 1, blockSize do
+ subframes[2][i] = subframes[1][i] - subframes[2][i]
+ end
+ elseif (chanAsgn == 9) then
+ for i = 1, blockSize do
+ subframes[1][i] = subframes[1][i] + subframes[2][i]
+ end
+ elseif (chanAsgn == 10) then
+ for i = 1, blockSize do
+ local side = subframes[2][i]
+ local right = subframes[1][i] - math_floor(side / 2)
+ subframes[2][i] = right
+ subframes[1][i] = right + side
+ end
+ end
+ else
+ error("Reserved channel assignment");
+ end
+ for ch = 1, #result do
+ for i = 1, blockSize do
+ local s = subframes[ch][i]
+ if s >= 2^(sampleDepth-1) then s = s - 2^sampleDepth end
+ result[ch][i] = s / 2^sampleDepth
+ end
+ end
+ end
+
+ local function decodeFrame(inp, numChannels, sampleDepth, out2, callback)
+ local out = {}
+ for i = 1, numChannels do out[i] = {} end
+ -- Read a ton of header fields, and ignore most of them
+ local temp = inp.readByte()
+ if temp == nil then
+ return false
+ end
+ local sync = temp * 64 + inp.readUint(6);
+ if sync ~= 0x3FFE then error("Sync code expected") end
+
+ inp.readUint(2);
+ local blockSizeCode = inp.readUint(4);
+ local sampleRateCode = inp.readUint(4);
+ local chanAsgn = inp.readUint(4);
+ inp.readUint(4);
+
+ temp = inp.readUint(8);
+ local t2 = -1
+ for i = 7, 0, -1 do if not bit32_btest(temp, 2^i) then break end t2 = t2 + 1 end
+ for i = 1, t2 do inp.readUint(8) end
+
+ local blockSize
+ if (blockSizeCode == 1) then
+ blockSize = 192
+ elseif (2 <= blockSizeCode and blockSizeCode <= 5) then
+ blockSize = 576 * 2^(blockSizeCode - 2)
+ elseif (blockSizeCode == 6) then
+ blockSize = inp.readUint(8) + 1
+ elseif (blockSizeCode == 7) then
+ blockSize = inp.readUint(16) + 1
+ elseif (8 <= blockSizeCode and blockSizeCode <= 15) then
+ blockSize = 256 * 2^(blockSizeCode - 8)
+ else
+ error("Reserved block size")
+ end
+
+ if (sampleRateCode == 12) then
+ inp.readUint(8)
+ elseif (sampleRateCode == 13 or sampleRateCode == 14) then
+ inp.readUint(16)
+ end
+
+ inp.readUint(8)
+
+ decodeSubframes(inp, sampleDepth, chanAsgn, blockSize, out)
+ inp.alignToByte()
+ inp.readUint(16)
+
+ if callback then callback(out) else
+ for c = 1, numChannels do
+ local n = #out2[c]
+ for i = 1, blockSize do out2[c][n+i] = out[c][i] end
+ end
+ end
+
+ return true
+ end
+
+ function decodeFLAC(inp, callback, head)
+ local out = {}
+ local pos = 1
+ -- Handle FLAC header and metadata blocks
+ local temp temp, pos = intunpack(inp, pos, 4, false, true)
+ if temp ~= 0x664C6143 then error("Invalid magic string") end
+ local sampleRate, numChannels, sampleDepth, numSamples
+ local last = false
+ local meta = {}
+ while not last do
+ temp, pos = str_byte(inp, pos), pos + 1
+ last = bit32_btest(temp, 0x80)
+ local type = bit32_band(temp, 0x7F);
+ local length length, pos = intunpack(inp, pos, 3, false, true)
+ if type == 0 then -- Stream info block
+ pos = pos + 10
+ sampleRate, pos = intunpack(inp, pos, 2, false, true)
+ sampleRate = sampleRate * 16 + bit32_rshift(str_byte(inp, pos), 4)
+ numChannels = bit32_band(bit32_rshift(str_byte(inp, pos), 1), 7) + 1;
+ sampleDepth = bit32_band(str_byte(inp, pos), 1) * 16 + bit32_rshift(str_byte(inp, pos+1), 4) + 1;
+ numSamples, pos = intunpack(inp, pos + 2, 4, false, true)
+ numSamples = numSamples + bit32_band(str_byte(inp, pos-5), 15) * 2^32
+ pos = pos + 16
+ elseif type == 4 then
+ local ncomments
+ meta.vendor, ncomments, pos = str_unpack(" 3000 then start = os_epoch "utc" sleep(0) end
+ local x = (i - 1) / ratio + 1
+ if x % 1 == 0 then line[i] = c[x]
+ else line[i] = clamp(interp(c, x), -1, 1) end
+ end
+ new.data[y] = line
+ end
+ return new
+end
+
+--- Mixes down all channels to a new mono-channel audio object.
+---@return aukit.Audio _ A new audio object with the audio mixed to mono
+function Audio:mono()
+ local new = setmetatable({sampleRate = self.sampleRate, data = {{}}, metadata = copy(self.metadata), info = copy(self.info)}, Audio_mt)
+ local ndata = new.data[1]
+ local cn = #self.data
+ local start = os_epoch "utc"
+ for i = 1, #self.data[1] do
+ if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end
+ local s = 0
+ for c = 1, cn do s = s + self.data[c][i] end
+ ndata[i] = s / cn
+ end
+ return new
+end
+
+--- Concatenates this audio object with another, adding the contents of each
+--- new channel to the end of each old channel, resampling the new channels to match
+--- this one (if necessary), and inserting silence in any missing channels.
+---@param ... aukit.Audio The audio objects to concatenate
+---@return aukit.Audio _ The new concatenated audio object
+function Audio:concat(...)
+ local audios = {self, ...}
+ local l = {#self.data[1]}
+ local cn = #self.data
+ for i = 2, #audios do
+ expectAudio(i-1, audios[i])
+ if audios[i].sampleRate ~= self.sampleRate then audios[i] = audios[i]:resample(self.sampleRate) end
+ l[i] = #audios[i].data[1]
+ cn = math_max(cn, #audios[i].data)
+ end
+ local obj = setmetatable({sampleRate = self.sampleRate, data = {}, metadata = copy(self.metadata), info = copy(self.info)}, Audio_mt)
+ for c = 1, cn do
+ local ch = {}
+ local pos = 0
+ for a = 1, #audios do
+ local sch = audios[a].data[c]
+ if sch then for i = 1, l[a] do ch[pos+i] = sch[i] end
+ else for i = 1, l[a] do ch[pos+i] = 0 end end
+ pos = pos + l[a]
+ end
+ obj.data[c] = ch
+ end
+ return obj
+end
+
+--- Takes a subregion of the audio and returns a new audio object with its contents.
+--- This takes the same arguments as `string.sub`, but positions start at 0.
+---@param start? number The start position of the audio in seconds
+---@param last? number The end position of the audio in seconds (0 means end of file)
+---@return aukit.Audio _ The new split audio object
+function Audio:sub(start, last)
+ start = math_floor(expect(1, start, "number", "nil") or 0)
+ last = math_floor(expect(2, last, "number", "nil") or 0)
+ local len = #self.data[1] / self.sampleRate
+ if start < 0 then start = len + start end
+ if last <= 0 then last = len + last end
+ expect.range(start, 0, len)
+ expect.range(last, 0, len)
+ start, last = start * self.sampleRate + 1, last * self.sampleRate + 1
+ local obj = setmetatable({sampleRate = self.sampleRate, data = {}, metadata = copy(self.metadata), info = copy(self.info)}, Audio_mt)
+ for c = 1, #self.data do
+ local ch = {}
+ local sch = self.data[c]
+ for i = start, last do ch[i-start+1] = sch[i] end
+ obj.data[c] = ch
+ end
+ return obj
+end
+
+--- Combines the channels of this audio object with another, adding the new
+--- channels on the end of the new object, resampling the new channels to match
+--- this one (if necessary), and extending any channels that are shorter than the
+--- longest channel with zeroes.
+---@param ... aukit.Audio The audio objects to combine with
+---@return aukit.Audio _ The new combined audio object
+function Audio:combine(...)
+ local audios = {self, ...}
+ local len = #self.data[1]
+ for i = 2, #audios do
+ expectAudio(i-1, audios[i])
+ if audios[i].sampleRate ~= self.sampleRate then audios[i] = audios[i]:resample(self.sampleRate) end
+ len = math_max(len, #audios[i].data[1])
+ end
+ local obj = setmetatable({sampleRate = self.sampleRate, data = {}, metadata = copy(self.metadata), info = copy(self.info)}, Audio_mt)
+ local pos = 0
+ for a = 1, #audios do
+ for c = 1, #audios[a].data do
+ local sch, ch = audios[a].data[c], {}
+ for i = 1, len do ch[i] = sch[i] or 0 end
+ obj.data[pos+c] = ch
+ end
+ pos = pos + #audios[a].data
+ end
+ return obj
+end
+
+--- Splits this audio object into one or more objects with the specified channels.
+--- Passing a channel that doesn't exist will throw an error.
+---@param ... number[] The lists of channels in each new object
+---@return aukit.Audio ... The new audio objects created from the channels in each list
+---@usage Split a stereo track into independent mono objects
+--
+--- local left, right = stereo:split({1}, {2})
+function Audio:split(...)
+ local retval = {}
+ for n, cl in ipairs{...} do
+ expect(n, cl, "table")
+ if #cl == 0 then error("bad argument #" .. n .. " (cannot use empty table)") end
+ local obj = setmetatable({sampleRate = self.sampleRate, data = {}, metadata = copy(self.metadata), info = copy(self.info)}, Audio_mt)
+ for cd, cs in ipairs(cl) do
+ local sch, ch = self.data[expect(cd, cs, "number")], {}
+ if not sch then error("channel " .. cs .. " (in argument " .. n .. ") out of range", 2) end
+ for i = 1, #sch do ch[i] = sch[i] end
+ obj.data[cd] = ch
+ end
+ retval[#retval+1] = obj
+ end
+ return table_unpack(retval)
+end
+
+--- Mixes two or more audio objects into a single object, amplifying each sample
+--- with a multiplier (before clipping) if desired, and clipping any values
+--- outside the audio range ([-1, 1]). Channels that are shorter are padded with
+--- zeroes at the end, and non-existent channels are replaced with all zeroes.
+--- Any audio objects with a different sample rate are resampled to match this one.
+---@param amplifier number|Audio The multiplier to apply, or the first audio object
+---@param ... aukit.Audio The objects to mix with this one
+---@return aukit.Audio _ The new mixed audio object
+function Audio:mix(amplifier, ...)
+ local audios = {self, ...}
+ local len = #self.data[1]
+ local cn = #self.data
+ for i = 2, #audios do
+ expectAudio(i, audios[i])
+ if audios[i].sampleRate ~= self.sampleRate then audios[i] = audios[i]:resample(self.sampleRate) end
+ len = math_max(len, #audios[i].data[1])
+ cn = math_max(cn, #audios[i].data)
+ end
+ if type(amplifier) ~= "number" then
+ expectAudio(1, amplifier)
+ if amplifier.sampleRate ~= self.sampleRate then amplifier = amplifier:resample(self.sampleRate) end
+ len = math_max(len, #amplifier.data[1])
+ cn = math_max(cn, #amplifier.data)
+ table_insert(audios, 2, amplifier)
+ amplifier = 1
+ end
+ local obj = setmetatable({sampleRate = self.sampleRate, data = {}, metadata = copy(self.metadata), info = copy(self.info)}, Audio_mt)
+ for c = 1, cn do
+ local ch = {}
+ local sch = {}
+ for a = 1, #audios do sch[a] = audios[a].data[c] end
+ for i = 1, len do
+ local s = 0
+ for a = 1, #audios do if sch[a] then s = s + (sch[a][i] or 0) end end
+ ch[i] = clamp(s * amplifier, -1, 1)
+ end
+ obj.data[c] = ch
+ end
+ return obj
+end
+
+--- Returns a new audio object that repeats this audio a number of times.
+---@param count number The number of times to play the audio
+---@return aukit.Audio _ The repeated audio
+function Audio:rep(count)
+ if type(self) ~= "table" and type(count) == "table" then self, count = count, self end
+ expect(1, count, "number")
+ local obj = setmetatable({sampleRate = self.sampleRate, data = {}, metadata = copy(self.metadata), info = copy(self.info)}, Audio_mt)
+ for c = 1, #self.data do
+ local sch, ch = self.data[c], {}
+ for n = 0, count - 1 do
+ local pos = n * #sch
+ for i = 1, #sch do ch[pos+i] = sch[i] end
+ end
+ obj.data[c] = ch
+ end
+ return obj
+end
+
+--- Returns a reversed version of this audio.
+---@return aukit.Audio _ The reversed audio
+function Audio:reverse()
+ local obj = setmetatable({sampleRate = self.sampleRate, data = {}, metadata = copy(self.metadata), info = copy(self.info)}, Audio_mt)
+ for c = 1, #self.data do
+ local sch, ch = self.data[c], {}
+ local len = #sch
+ for i = 1, len do ch[len-i+1] = sch[i] end
+ obj.data[c] = ch
+ end
+ return obj
+end
+
+local function encodePCM(info, pos)
+ local maxValue = 2^(info.bitDepth-1)
+ local add = info.dataType == "unsigned" and maxValue or 0
+ local source = info.audio.data
+ local encode
+ if info.dataType == "float" then encode = function(d) return d end
+ else encode = function(d) return d * (d < 0 and maxValue or maxValue-1) + add end end
+ local data = {}
+ local nc = #source
+ local len = #source[1]
+ if pos > len then return nil end
+ local start = os_epoch "utc"
+ if info.interleaved then for n = pos, pos + info.len - 1 do if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end for c = 1, nc do data[(n-1)*nc+c] = encode(source[c][n]) end end
+ elseif info.multiple then
+ for c = 1, nc do
+ data[c] = {}
+ for n = pos, pos + info.len - 1 do
+ if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end
+ local s = source[c][n]
+ if not s then break end
+ data[c][n-pos+1] = encode(s)
+ end
+ end
+ return pos + info.len, table_unpack(data)
+ else for c = 1, nc do for n = pos, pos + info.len - 1 do if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end data[(c-1)*len+n] = encode(source[c][n]) end end end
+ return data
+end
+
+--- Converts the audio data to raw PCM samples.
+---@param bitDepth? number The bit depth of the audio (8, 16, 24, 32)
+---@param dataType? "signed"|"unsigned"|"float" The type of each sample
+---@param interleaved? boolean Whether to interleave each channel
+---@return number[]|nil ... The resulting audio data
+function Audio:pcm(bitDepth, dataType, interleaved)
+ bitDepth = expect(1, bitDepth, "number", "nil") or 8
+ dataType = expect(2, dataType, "string", "nil") or "signed"
+ expect(3, interleaved, "boolean", "nil")
+ if interleaved == nil then interleaved = true end
+ if bitDepth ~= 8 and bitDepth ~= 16 and bitDepth ~= 24 and bitDepth ~= 32 then error("bad argument #2 (invalid bit depth)", 2) end
+ if dataType ~= "signed" and dataType ~= "unsigned" and dataType ~= "float" then error("bad argument #3 (invalid data type)", 2) end
+ if dataType == "float" and bitDepth ~= 32 then error("bad argument #2 (float audio must have 32-bit depth)", 2) end
+ return encodePCM({audio = self, bitDepth = bitDepth, dataType = dataType, interleaved = interleaved, len = #self.data[1]}, 1)
+end
+
+--- Returns a function that can be called to encode PCM samples in chunks.
+--- This is useful as a for iterator, and can be used with `aukit.play`.
+---@param chunkSize? number The size of each chunk
+---@param bitDepth? number The bit depth of the audio (8, 16, 24, 32)
+---@param dataType? "signed"|"unsigned"|"float" The type of each sample
+---@return fun():number[][]|nil,number|nil _ An iterator function that returns
+--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as
+--- the current position of the audio in seconds
+---@return number _ The total length of the audio in seconds
+function Audio:stream(chunkSize, bitDepth, dataType)
+ chunkSize = expect(1, chunkSize, "number", "nil") or 131072
+ bitDepth = expect(2, bitDepth, "number", "nil") or 8
+ dataType = expect(3, dataType, "string", "nil") or "signed"
+ if bitDepth ~= 8 and bitDepth ~= 16 and bitDepth ~= 24 and bitDepth ~= 32 then error("bad argument #2 (invalid bit depth)", 2) end
+ if dataType ~= "signed" and dataType ~= "unsigned" and dataType ~= "float" then error("bad argument #3 (invalid data type)", 2) end
+ if dataType == "float" and bitDepth ~= 32 then error("bad argument #2 (float audio must have 32-bit depth)", 2) end
+ local info, pos = {audio = self, bitDepth = bitDepth, dataType = dataType, interleaved = false, multiple = true, len = chunkSize}, 1
+ return function()
+ if info == nil then return nil end
+ local p = pos / self.sampleRate
+ local v = {encodePCM(info, pos)}
+ if v[1] == nil then info = nil return nil end
+ pos = table_remove(v, 1)
+ return v, p
+ end, #self.data[1] / self.sampleRate
+end
+
+--- Coverts the audio data to a WAV file.
+---@param bitDepth? number The bit depth of the audio (1 = DFPWM, 8, 16, 24, 32)
+---@return string _ The resulting WAV file data
+function Audio:wav(bitDepth)
+ -- TODO: Support float data
+ bitDepth = expect(1, bitDepth, "number", "nil") or 16
+ if bitDepth == 1 then
+ local str = self:dfpwm(true)
+ if self.metadata and next(self.metadata) then
+ local info = {}
+ for k, v in pairs(self.metadata) do
+ for l, w in pairs(wavMetadata) do
+ if w == k then
+ info[#info+1] = l
+ info[#info+1] = tostring(v)
+ break
+ end
+ end
+ end
+ local list = str_pack("!2" or "<"
+ local sformat = dataType == "float" and "f" or ((dataType == "signed" and "i" or "I") .. byteDepth)
+ local format = bitDir .. str_rep(sformat, csize)
+ local maxValue = 2^(bitDepth-1)
+ local obj = setmetatable({sampleRate = sampleRate, data = {}, metadata = {}, info = {bitDepth = bitDepth, dataType = dataType}}, Audio_mt)
+ for i = 1, channels do obj.data[i] = {} end
+ local pos, spos = 1, 1
+ local tmp = {}
+ local read
+ if type(data) == "table" then
+ if dataType == "signed" then
+ function read()
+ local s = data[pos]
+ pos = pos + 1
+ return s / (s < 0 and maxValue or maxValue-1)
+ end
+ elseif dataType == "unsigned" then
+ function read()
+ local s = data[pos]
+ pos = pos + 1
+ return (s - 128) / (s < 128 and maxValue or maxValue-1)
+ end
+ else
+ function read()
+ local s = data[pos]
+ pos = pos + 1
+ return s
+ end
+ end
+ elseif dataType == "float" then
+ function read()
+ if pos > #tmp then
+ if spos + csizeb > #data then
+ local f = bitDir .. str_rep(sformat, (#data - spos + 1) / byteDepth)
+ tmp = {str_unpack(f, data, spos)}
+ spos = tmp[#tmp]
+ tmp[#tmp] = nil
+ else
+ tmp = {str_unpack(format, data, spos)}
+ spos = tmp[#tmp]
+ tmp[#tmp] = nil
+ end
+ pos = 1
+ end
+ local s = tmp[pos]
+ pos = pos + 1
+ return s
+ end
+ elseif dataType == "signed" then
+ function read()
+ if pos > #tmp then
+ if spos + csizeb > #data then
+ local f = bitDir .. str_rep(sformat, (#data - spos + 1) / byteDepth)
+ tmp = {str_unpack(f, data, spos)}
+ spos = tmp[#tmp]
+ tmp[#tmp] = nil
+ else
+ tmp = {str_unpack(format, data, spos)}
+ spos = tmp[#tmp]
+ tmp[#tmp] = nil
+ end
+ pos = 1
+ end
+ local s = tmp[pos]
+ pos = pos + 1
+ return s / (s < 0 and maxValue or maxValue-1)
+ end
+ else -- unsigned
+ function read()
+ if pos > #tmp then
+ if spos + csizeb > #data then
+ local f = bitDir .. str_rep(sformat, (#data - spos + 1) / byteDepth)
+ tmp = {str_unpack(f, data, spos)}
+ spos = tmp[#tmp]
+ tmp[#tmp] = nil
+ else
+ tmp = {str_unpack(format, data, spos)}
+ spos = tmp[#tmp]
+ tmp[#tmp] = nil
+ end
+ pos = 1
+ end
+ local s = tmp[pos]
+ pos = pos + 1
+ return (s - 128) / (s < 128 and maxValue or maxValue-1)
+ end
+ end
+ local start = os_epoch "utc"
+ if interleaved and channels > 1 then
+ local d = obj.data
+ for i = 1, len do
+ if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end
+ for j = 1, channels do d[j][i] = read() end
+ end
+ else for j = 1, channels do
+ local line = {}
+ obj.data[j] = line
+ for i = 1, len do
+ if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end
+ line[i] = read()
+ end
+ end end
+ return obj
+end
+
+--- Creates a new audio object from IMA ADPCM data.
+---@param data string|table The audio data, either as a raw string, or a table of nibbles
+---@param channels? number The number of channels present in the audio
+---@param sampleRate? number The sample rate of the audio in Hertz
+---@param topFirst? boolean Whether the top nibble is the first nibble
+--- (true) or last (false); ignored if `data` is a table
+---@param interleaved? boolean Whether each channel is interleaved or separate
+---@param predictor? number|table The initial predictor value(s)
+---@param step_index? number|table The initial step index(es)
+---@return aukit.Audio _ A new audio object containing the decoded data
+function aukit.adpcm(data, channels, sampleRate, topFirst, interleaved, predictor, step_index)
+ expect(1, data, "string", "table")
+ channels = expect(2, channels, "number", "nil") or 1
+ sampleRate = expect(3, sampleRate, "number", "nil") or 48000
+ expect(4, topFirst, "boolean", "nil")
+ if topFirst == nil then topFirst = true end
+ expect(5, interleaved, "boolean", "nil")
+ if interleaved == nil then interleaved = true end
+ predictor = expect(6, predictor, "number", "table", "nil")
+ step_index = expect(7, step_index, "number", "table", "nil")
+ expect.range(channels, 1)
+ expect.range(sampleRate, 1)
+ if predictor == nil then
+ predictor = {}
+ for i = 1, channels do predictor[i] = 0 end
+ elseif type(predictor) == "number" then
+ if channels ~= 1 then error("bad argument #6 (table too short)", 2) end
+ predictor = {expect.range(predictor, -32768, 32767)}
+ else
+ if channels > #predictor then error("bad argument #6 (table too short)", 2) end
+ for i = 1, channels do expect.range(predictor[i], -32768, 32767) end
+ end
+ if step_index == nil then
+ step_index = {}
+ for i = 1, channels do step_index[i] = 0 end
+ elseif type(step_index) == "number" then
+ if channels ~= 1 then error("bad argument #7 (table too short)", 2) end
+ step_index = {expect.range(step_index, 0, 88)}
+ else
+ if channels > #step_index then error("bad argument #7 (table too short)", 2) end
+ for i = 1, channels do expect.range(step_index[i], 0, 88) end
+ end
+ local pos = 1
+ local read, tmp, len
+ if type(data) == "string" then
+ function read()
+ if tmp then
+ local v = tmp
+ tmp = nil
+ return v
+ else
+ local b = str_byte(data, pos)
+ pos = pos + 1
+ if topFirst then tmp, b = bit32_band(b, 0x0F), bit32_rshift(b, 4)
+ else tmp, b = bit32_rshift(b, 4), bit32_band(b, 0x0F) end
+ return b
+ end
+ end
+ len = math_floor(#data * 2 / channels)
+ else
+ function read()
+ local v = data[pos]
+ pos = pos + 1
+ return v
+ end
+ len = #data / channels
+ end
+ local obj = setmetatable({sampleRate = sampleRate, data = {}, metadata = {}, info = {bitDepth = 16, dataType = "signed"}}, Audio_mt)
+ local step = {}
+ local start = os_epoch "utc"
+ if interleaved then
+ local d = obj.data
+ for j = 1, channels do d[j] = {} end
+ for i = 1, len do
+ if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end
+ for j = 1, channels do
+ local nibble = read()
+ step[j] = ima_step_table[step_index[j]]
+ step_index[j] = clamp(step_index[j] + ima_index_table[nibble], 0, 88)
+ local diff = bit32_rshift((nibble % 8) * step[j], 2) + bit32_rshift(step[j], 3)
+ if nibble >= 8 then predictor[j] = clamp(predictor[j] - diff, -32768, 32767)
+ else predictor[j] = clamp(predictor[j] + diff, -32768, 32767) end
+ d[j][i] = predictor[j] / (predictor[j] < 0 and 32768 or 32767)
+ end
+ end
+ else for j = 1, channels do
+ local line = {}
+ local predictor, step_index, step = predictor[j], step_index[j], nil
+ for i = 1, len do
+ if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end
+ local nibble = read()
+ step = ima_step_table[step_index]
+ step_index = clamp(step_index + ima_index_table[nibble], 0, 88)
+ local diff = bit32_rshift((nibble % 8) * step, 2) + bit32_rshift(step, 3)
+ if nibble >= 8 then predictor = clamp(predictor - diff, -32768, 32767)
+ else predictor = clamp(predictor + diff, -32768, 32767) end
+ line[i] = predictor / (predictor < 0 and 32768 or 32767)
+ end
+ obj.data[j] = line
+ end end
+ return obj
+end
+
+--- Creates a new audio object from Microsoft ADPCM data.
+---@param data string The audio data as a raw string
+---@param blockAlign number The number of bytes in each block
+---@param channels? number The number of channels present in the audio
+---@param sampleRate? number The sample rate of the audio in Hertz
+---@param coefficients? table Two lists of coefficients to use
+---@return aukit.Audio _ A new audio object containing the decoded data
+function aukit.msadpcm(data, blockAlign, channels, sampleRate, coefficients)
+ expect(1, data, "string")
+ expect(2, blockAlign, "number")
+ channels = expect(3, channels, "number", "nil") or 1
+ sampleRate = expect(4, sampleRate, "number", "nil") or 48000
+ expect(5, coefficients, "table", "nil")
+ expect.range(sampleRate, 1)
+ local coeff1, coeff2
+ if coefficients then
+ if type(coefficients[1]) ~= "table" then error("bad argument #5 (first entry is not a table)", 2) end
+ if type(coefficients[2]) ~= "table" then error("bad argument #5 (second entry is not a table)", 2) end
+ --if #coefficients[1] ~= #coefficients[2] then error("bad argument #5 (lists are not the same length)", 2) end
+ --coeff1, coeff2 = {}, {}
+ for i, v in ipairs(coefficients[1]) do
+ if type(v) ~= "number" then error("bad entry #" .. i .. " in coefficient list 1 (expected number, got " .. type(v) .. ")", 2) end
+ coeff1[i-1] = v
+ end
+ for i, v in ipairs(coefficients[2]) do
+ if type(v) ~= "number" then error("bad entry #" .. i .. " in coefficient list 2 (expected number, got " .. type(v) .. ")", 2) end
+ coeff2[i-1] = v
+ end
+ else coeff1, coeff2 = {[0] = 256, 512, 0, 192, 240, 460, 392}, {[0] = 0, -256, 0, 64, 0, -208, -232} end
+ local obj = setmetatable({sampleRate = sampleRate, data = {{}, channels == 2 and {} or nil}, metadata = {}, info = {bitDepth = 16, dataType = "signed"}}, Audio_mt)
+ local left, right = obj.data[1], obj.data[2]
+ local start = os_epoch "utc"
+ for n = 1, #data, blockAlign do
+ if channels == 2 then
+ local predictorIndexL, predictorIndexR, deltaL, deltaR, sample1L, sample1R, sample2L, sample2R = str_unpack("= 8 then hi = hi - 16 end
+ if lo >= 8 then lo = lo - 16 end
+ local predictor = clamp(math_floor((sample1L * c1L + sample2L * c2L) / 256) + hi * deltaL, -32768, 32767)
+ left[#left+1] = predictor / (predictor < 0 and 32768 or 32767)
+ sample2L, sample1L = sample1L, predictor
+ deltaL = math_max(math_floor(msadpcm_adaption_table[hi] * deltaL / 256), 16)
+ predictor = clamp(math_floor((sample1R * c1R + sample2R * c2R) / 256) + lo * deltaR, -32768, 32767)
+ right[#right+1] = predictor / (predictor < 0 and 32768 or 32767)
+ sample2R, sample1R = sample1R, predictor
+ deltaR = math_max(math_floor(msadpcm_adaption_table[lo] * deltaR / 256), 16)
+ end
+ elseif channels == 1 then
+ local predictorIndex, delta, sample1, sample2 = str_unpack("= 8 then hi = hi - 16 end
+ if lo >= 8 then lo = lo - 16 end
+ local predictor = clamp(math_floor((sample1 * c1 + sample2 * c2) / 256) + hi * delta, -32768, 32767)
+ left[#left+1] = predictor / (predictor < 0 and 32768 or 32767)
+ sample2, sample1 = sample1, predictor
+ delta = math_max(math_floor(msadpcm_adaption_table[hi] * delta / 256), 16)
+ predictor = clamp(math_floor((sample1 * c1 + sample2 * c2) / 256) + lo * delta, -32768, 32767)
+ left[#left+1] = predictor / (predictor < 0 and 32768 or 32767)
+ sample2, sample1 = sample1, predictor
+ delta = math_max(math_floor(msadpcm_adaption_table[lo] * delta / 256), 16)
+ end
+ else error("Unsupported number of channels: " .. channels) end
+ if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end
+ end
+ return obj
+end
+
+--- Creates a new audio object from G.711 u-law/A-law data.
+---@param data string The audio data as a raw string
+---@param ulaw boolean Whether the audio uses u-law (true) or A-law (false).
+---@param channels? number The number of channels present in the audio
+---@param sampleRate? number The sample rate of the audio in Hertz
+---@return aukit.Audio _ A new audio object containing the decoded data
+function aukit.g711(data, ulaw, channels, sampleRate)
+ expect(1, data, "string")
+ expect(2, ulaw, "boolean")
+ channels = expect(3, channels, "number", "nil") or 1
+ sampleRate = expect(4, sampleRate, "number", "nil") or 8000
+ local retval = {}
+ local csize = jit and 7680 or 32768
+ local xor = ulaw and 0xFF or 0x55
+ for i = 1, channels do retval[i] = {} end
+ local start = os_epoch "utc"
+ for i = 1, #data, csize do
+ local bytes = {str_byte(data, i, i + csize - 1)}
+ for j = 1, #bytes do
+ local b = bit32_bxor(bytes[j], xor)
+ local m, e = bit32_band(b, 0x0F), bit32_extract(b, 4, 3)
+ if not ulaw and e == 0 then m = m * 4 + 2
+ else m = bit32_lshift(m * 2 + 33, e) end
+ if ulaw then m = m - 33 end
+ retval[(i+j-2) % channels + 1][math_floor((i+j-2) / channels + 1)] = m / (bit32_btest(b, 0x80) == ulaw and -0x2000 or 0x2000)
+ end
+ if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end
+ end
+ return setmetatable({sampleRate = sampleRate, data = retval, metadata = {bitDepth = ulaw and 14 or 13, dataType = "signed"}, info = {}}, Audio_mt)
+end
+
+--- Creates a new audio object from DFPWM1a data. All channels are expected to
+--- share the same decoder, and are stored interleaved in a single stream.
+---@param data string The audio data as a raw string
+---@param channels? number The number of channels present in the audio
+---@param sampleRate? number The sample rate of the audio in Hertz
+---@return aukit.Audio _ A new audio object containing the decoded data
+function aukit.dfpwm(data, channels, sampleRate)
+ expect(1, data, "string")
+ channels = expect(2, channels, "number", "nil") or 1
+ sampleRate = expect(3, sampleRate, "number", "nil") or 48000
+ expect.range(channels, 1)
+ expect.range(sampleRate, 1)
+ local audio = {}
+ local decoder = dfpwm.make_decoder()
+ local pos = 1
+ local last = 0
+ local start = os_epoch "utc"
+ while pos <= #data do
+ if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end
+ local temp = decoder(str_sub(data, pos, pos + 6000))
+ if temp == nil or #temp == 0 then break end
+ for i=1,#temp do
+ audio[last+i] = temp[i]
+ end
+ last = last + #temp
+ pos = pos + 6000
+ end
+ return aukit.pcm(audio, 8, "signed", channels, sampleRate, true, false)
+end
+
+--- Creates a new audio object from MDFPWMv3 data.
+---@param data string The audio data as a raw string
+---@param head boolean Whether to only load metadata (header data) - this will not decode the audio
+---@return aukit.Audio _ A new audio object containing the decoded data
+function aukit.mdfpwm(data, head)
+ expect(1, data, "string")
+ if data:sub(1, 7) ~= "MDFPWM\3" then error("bad argument #1 (not a MDFPWM file)", 2) end
+ local length, artist, title, album, pos = (" 3000 then start = os_epoch "utc" sleep(0) end
+ local tempL = decoderL(str_sub(data, pos, pos + 5999))
+ if tempL == nil or #tempL == 0 then break end
+ for i = 1, #tempL do audio[last+i*2-1] = tempL[i] end
+ local tempR = decoderR(str_sub(data, pos + 6000, pos + 11999 ))
+ if tempR == nil or #tempR == 0 then break end
+ for i = 1, #tempR do audio[last+i*2] = tempR[i] end
+ last = last + #tempL + #tempR
+ pos = pos + 12000
+ end
+ for i = length * 8 + 1, #audio do audio[i] = nil end
+ local obj = aukit.pcm(audio, 8, "signed", 2, 48000, true, false)
+ obj.metadata = {artist = artist, title = title, album = album}
+ return obj
+end
+
+--- Creates a new audio object from a WAV file. This accepts PCM files up to 32
+--- bits, including float data, as well as DFPWM files [as specified here](https://gist.github.com/MCJack123/90c24b64c8e626c7f130b57e9800962c),
+--- plus IMA and Microsoft ADPCM formats and G.711 u-law/A-law.
+---@param data string The WAV data to load
+---@param head boolean Whether to only load metadata (header data) - this will not decode the audio
+---@return aukit.Audio _ A new audio object with the contents of the WAV file
+function aukit.wav(data, head)
+ expect(1, data, "string")
+ local channels, sampleRate, bitDepth, length, dataType, blockAlign, coefficients
+ local temp, pos = str_unpack("c4", data)
+ if temp ~= "RIFF" then error("bad argument #1 (not a WAV file)", 2) end
+ pos = pos + 4
+ temp, pos = str_unpack("c4", data, pos)
+ if temp ~= "WAVE" then error("bad argument #1 (not a WAV file)", 2) end
+ local meta = {}
+ local obj
+ while pos <= #data do
+ local size
+ temp, size, pos = str_unpack(" 0 then
+ coefficients = {{}, {}}
+ for i = 1, numcoeff do
+ coefficients[1][i], coefficients[2][i] = str_unpack("c4I", data, pos)
+ if temp == "COMM" then
+ local e, m
+ channels, length, bitDepth, e, m, pos = str_unpack(">hIhHI7x", data, pos)
+ if isAIFC then
+ local s
+ compression, s, pos = str_unpack(">c4s1", data, pos)
+ if #s % 2 == 0 then pos = pos + 1 end
+ end
+ length = length * channels * math_floor(bitDepth / 8)
+ local s = bit32_btest(e, 0x8000)
+ e = ((bit32_band(e, 0x7FFF) - 0x3FFE) % 0x800)
+ sampleRate = math.ldexp(m * (s and -1 or 1) / 0x100000000000000, e)
+ elseif temp == "SSND" then
+ offset, blockAlign, pos = str_unpack(">II", data, pos)
+ local data = str_sub(data, pos + offset, pos + offset + length - 1)
+ --if #data < length then error("invalid AIFF file", 2) end
+ local obj
+ if head then obj = aukit.new(0, channels, sampleRate)
+ elseif compression == nil or compression == "NONE" then obj = aukit.pcm(data, bitDepth, "signed", channels, sampleRate, true, true)
+ elseif compression == "sowt" then obj = aukit.pcm(data, bitDepth, "signed", channels, sampleRate, true, false)
+ elseif compression == "fl32" or compression == "FL32" then obj = aukit.pcm(data, 32, "float", channels, sampleRate, true, true)
+ elseif compression == "alaw" or compression == "ulaw" or compression == "ALAW" or compression == "ULAW" then obj = aukit.g711(data, compression == "ulaw" or compression == "ULAW", channels, sampleRate)
+ else error("Unsupported compression scheme " .. compression, 2) end
+ obj.metadata = meta
+ return obj
+ elseif temp == "NAME" then
+ meta.title = str_sub(data, pos, pos + size - 1)
+ pos = pos + size
+ elseif temp == "AUTH" then
+ meta.artist = str_sub(data, pos, pos + size - 1)
+ pos = pos + size
+ elseif temp == "(c) " then
+ meta.copyright = str_sub(data, pos, pos + size - 1)
+ pos = pos + size
+ elseif temp == "ANNO" then
+ meta.comment = str_sub(data, pos, pos + size - 1)
+ pos = pos + size
+ else pos = pos + size end
+ end
+ error("invalid AIFF file", 2)
+end
+
+--- Creates a new audio object from an AU file.
+---@param data string The AU data to load
+---@return aukit.Audio _ A new audio object with the contents of the AU file
+function aukit.au(data)
+ expect(1, data, "string")
+ local magic, offset, size, encoding, sampleRate, channels = str_unpack(">c4IIIII", data)
+ if magic ~= ".snd" then error("invalid AU file", 2) end
+ if encoding == 1 then return aukit.g711(str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil), true, channels, sampleRate)
+ elseif encoding == 2 then return aukit.pcm(str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil), 8, "signed", channels, sampleRate, true, true)
+ elseif encoding == 3 then return aukit.pcm(str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil), 16, "signed", channels, sampleRate, true, true)
+ elseif encoding == 4 then return aukit.pcm(str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil), 24, "signed", channels, sampleRate, true, true)
+ elseif encoding == 5 then return aukit.pcm(str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil), 32, "signed", channels, sampleRate, true, true)
+ elseif encoding == 6 then return aukit.pcm(str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil), 32, "float", channels, sampleRate, true, true)
+ elseif encoding == 27 then return aukit.g711(str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil), false, channels, sampleRate)
+ else error("unsupported encoding type " .. encoding, 2) end
+end
+
+--- Creates a new audio object from a FLAC file.
+---@param data string The FLAC data to load
+---@param head boolean Whether to only load metadata (header data) - this will not decode the audio
+---@return aukit.Audio _ A new audio object with the contents of the FLAC file
+function aukit.flac(data, head)
+ expect(1, data, "string")
+ return setmetatable(decodeFLAC(data, nil, head), Audio_mt)
+end
+
+local qoa_dequant_tab = {
+ [0] = {[0] = 1, -1, 3, -3, 5, -5, 7, -7},
+ {[0] = 5, -5, 18, -18, 32, -32, 49, -49},
+ {[0] = 16, -16, 53, -53, 95, -95, 147, -147},
+ {[0] = 34, -34, 113, -113, 203, -203, 315, -315},
+ {[0] = 63, -63, 210, -210, 378, -378, 588, -588},
+ {[0] = 104, -104, 345, -345, 621, -621, 966, -966},
+ {[0] = 158, -158, 528, -528, 950, -950, 1477, -1477},
+ {[0] = 228, -228, 760, -760, 1368, -1368, 2128, -2128},
+ {[0] = 316, -316, 1053, -1053, 1895, -1895, 2947, -2947},
+ {[0] = 422, -422, 1405, -1405, 2529, -2529, 3934, -3934},
+ {[0] = 548, -548, 1828, -1828, 3290, -3290, 5117, -5117},
+ {[0] = 696, -696, 2320, -2320, 4176, -4176, 6496, -6496},
+ {[0] = 868, -868, 2893, -2893, 5207, -5207, 8099, -8099},
+ {[0] = 1064, -1064, 3548, -3548, 6386, -6386, 9933, -9933},
+ {[0] = 1286, -1286, 4288, -4288, 7718, -7718, 12005, -12005},
+ {[0] = 1536, -1536, 5120, -5120, 9216, -9216, 14336, -14336},
+}
+
+local function signed_rshift(a, b)
+ local n = bit32_arshift(a, b)
+ if n >= 0x80000000 then return n - 0x100000000 else return n end
+end
+
+local function qoa_lms_predict(lms)
+ local w, h = lms.weights, lms.history
+ return signed_rshift(w[1] * h[1] + w[2] * h[2] + w[3] * h[3] + w[4] * h[4], 13)
+end
+
+local function qoa_lms_update(lms, sample, residual)
+ local delta = signed_rshift(residual, 4)
+ local w, h = lms.weights, lms.history
+ lms.weights = {
+ w[1] + (h[1] < 0 and -delta or delta),
+ w[2] + (h[2] < 0 and -delta or delta),
+ w[3] + (h[3] < 0 and -delta or delta),
+ w[4] + (h[4] < 0 and -delta or delta)
+ }
+ lms.history = {h[2], h[3], h[4], sample}
+end
+
+--- Creates a new audio object from a QOA file.
+---@param data string The QOA data to load
+---@return aukit.Audio _ A new audio object with the contents of the QOA file
+function aukit.qoa(data)
+ expect(1, data, "string")
+ local magic, file_samples, pos = (">c4I4"):unpack(data)
+ if magic ~= "qoaf" then error("Not a QOA file", 2) end
+ local file_channels, file_sampleRate = (">BI3"):unpack(data, pos)
+ local retval = setmetatable({sampleRate = file_sampleRate, data = {}, metadata = {}, info = {bitDepth = 16, dataType = "signed"}}, Audio_mt) ---@type aukit.Audio
+ local lms = {}
+ for i = 1, file_channels do
+ retval.data[i] = {}
+ lms[i] = {history = {}, weights = {}}
+ end
+
+ local start = os_epoch "utc"
+ local sample_pos = 0
+ while pos + 16 * file_channels + 8 <= #data and sample_pos < file_samples do
+ -- from https://github.com/phoboslab/qoa/blob/master/qoa.h
+ -- MIT license - Copyright (c) 2023 Dominic Szablewski
+
+ -- Read and verify the frame header
+ local channels, samplerate, samples, frame_size
+ channels, samplerate, samples, frame_size, pos = (">BI3I2I2"):unpack(data, pos)
+
+ local data_size = frame_size - 8 - 4 * 4 * channels
+ local num_slices = math_floor(data_size / 8)
+ local max_total_samples = num_slices * 20
+
+ if
+ channels ~= file_channels or
+ samplerate ~= file_sampleRate or
+ frame_size > #data - pos + 1 or
+ samples * channels > max_total_samples
+ then
+ --error("Bad frame data", 2)
+ break
+ end
+
+ -- Read the LMS state: 4 x 2 bytes history, 4 x 2 bytes weights per channel
+ for c = 1, channels do
+ lms[c].history = {(">i2i2i2i2"):unpack(data, pos)}
+ pos = table.remove(lms[c].history)
+ lms[c].weights = {(">i2i2i2i2"):unpack(data, pos)}
+ pos = table.remove(lms[c].weights)
+ end
+
+ -- Decode all slices for all channels in this frame
+ for sample_index = 1, samples, 20 do
+ for c = 1, channels do
+ local sliceH, sliceL
+ sliceH, sliceL, pos = (">I4I4"):unpack(data, pos)
+
+ local scalefactor = bit32_extract(sliceH, 28, 4)
+
+ for si = sample_index, sample_index + 19 do
+ local predicted = qoa_lms_predict(lms[c])
+ local quantized = bit32_extract(sliceH, 25, 3)
+ local dequantized = qoa_dequant_tab[scalefactor][quantized]
+ --print(predicted, dequantized)
+ local reconstructed = math_min(math_max(predicted + dequantized, -32768), 32767)
+
+ retval.data[c][sample_pos + si] = reconstructed / (reconstructed < 0 and 32768 or 32767)
+ sliceH = bit32_lshift(sliceH, 3) + bit32_extract(sliceL, 29, 3)
+ sliceL = bit32_lshift(sliceL, 3)
+
+ qoa_lms_update(lms[c], reconstructed, dequantized)
+ end
+ end
+ end
+ sample_pos = sample_pos + samples
+ if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end
+ end
+ return retval
+end
+
+--- Creates a new empty audio object with the specified duration.
+---@param duration number The length of the audio in seconds
+---@param channels? number The number of channels present in the audio
+---@param sampleRate? number The sample rate of the audio in Hertz
+---@return aukit.Audio _ The new empty audio object
+function aukit.new(duration, channels, sampleRate)
+ expect(1, duration, "number")
+ channels = expect(2, channels, "number", "nil") or 1
+ sampleRate = expect(3, sampleRate, "number", "nil") or 48000
+ expect.range(channels, 1)
+ expect.range(sampleRate, 1)
+ local obj = setmetatable({sampleRate = sampleRate, data = {}, metadata = {}, info = {}}, Audio_mt)
+ for c = 1, channels do
+ local l = {}
+ for i = 1, duration * sampleRate do l[i] = 0 end
+ obj.data[c] = l
+ end
+ return obj
+end
+
+--- Creates a new audio object with a tone of the specified frequency and duration.
+---@param frequency number The frequency of the tone in Hertz
+---@param duration number The length of the audio in seconds
+---@param amplitude? number The amplitude of the audio from 0.0 to 1.0
+---@param waveType? "sine"|"triangle"|"sawtooth"|"square" The type of wave to generate
+---@param duty? number The duty cycle of the square wave if selected; ignored otherwise
+---@param channels? number The number of channels present in the audio
+---@param sampleRate? number The sample rate of the audio in Hertz
+---@return aukit.Audio _ A new audio object with the tone
+function aukit.tone(frequency, duration, amplitude, waveType, duty, channels, sampleRate)
+ expect(1, frequency, "number")
+ expect(2, duration, "number")
+ amplitude = expect(3, amplitude, "number", "nil") or 1
+ waveType = expect(4, waveType, "string", "nil") or "sine"
+ duty = expect(5, duty, "number", "nil") or 0.5
+ channels = expect(6, channels, "number", "nil") or 1
+ sampleRate = expect(7, sampleRate, "number", "nil") or 48000
+ expect.range(amplitude, 0, 1)
+ local f = wavegen[waveType]
+ if not f then error("bad argument #4 (invalid wave type)", 2) end
+ expect.range(duty, 0, 1)
+ expect.range(channels, 1)
+ expect.range(sampleRate, 1)
+ local obj = setmetatable({sampleRate = sampleRate, data = {}, metadata = {}, info = {}}, Audio_mt)
+ for c = 1, channels do
+ local l = {}
+ for i = 1, duration * sampleRate do l[i] = f(i / sampleRate, frequency, amplitude, duty) end
+ obj.data[c] = l
+ end
+ return obj
+end
+
+--- Creates a new audio object with white noise for the specified duration.
+---@param duration number The length of the audio in seconds
+---@param amplitude? number The amplitude of the audio from 0.0 to 1.0
+---@param channels? number The number of channels present in the audio
+---@param sampleRate? number The sample rate of the audio in Hertz
+---@return aukit.Audio _ A new audio object with noise
+function aukit.noise(duration, amplitude, channels, sampleRate)
+ expect(1, duration, "number")
+ amplitude = expect(2, amplitude, "number", "nil") or 1
+ channels = expect(3, channels, "number", "nil") or 1
+ sampleRate = expect(4, sampleRate, "number", "nil") or 48000
+ expect.range(amplitude, 0, 1)
+ expect.range(channels, 1)
+ expect.range(sampleRate, 1)
+ local obj = setmetatable({sampleRate = sampleRate, data = {}, metadata = {}, info = {}}, Audio_mt)
+ local random = math.random
+ for c = 1, channels do
+ local l = {}
+ for i = 1, duration * sampleRate do l[i] = (random() * 2 - 1) * amplitude end
+ obj.data[c] = l
+ end
+ return obj
+end
+
+--- Packs a table with PCM data into a string using the specified data type.
+---@param data number[] The PCM data to pack
+---@param bitDepth? number The bit depth of the audio (8, 16, 24, 32); if `dataType` is "float" then this must be 32
+---@param dataType? "signed"|"unsigned"|"float" The type of each sample
+---@param bigEndian? boolean Whether the data should be big-endian or little-endian
+---@return string _ The packed PCM data
+function aukit.pack(data, bitDepth, dataType, bigEndian)
+ expect(1, data, "string", "table")
+ bitDepth = expect(2, bitDepth, "number", "nil") or 8
+ dataType = expect(3, dataType, "string", "nil") or "signed"
+ expect(4, bigEndian, "boolean", "nil")
+ if bitDepth ~= 8 and bitDepth ~= 16 and bitDepth ~= 24 and bitDepth ~= 32 then error("bad argument #2 (invalid bit depth)", 2) end
+ if dataType ~= "signed" and dataType ~= "unsigned" and dataType ~= "float" then error("bad argument #3 (invalid data type)", 2) end
+ if dataType == "float" and bitDepth ~= 32 then error("bad argument #2 (float audio must have 32-bit depth)", 2) end
+ local byteDepth = bitDepth / 8
+ local format = (bigEndian and ">" or "<") .. (dataType == "float" and "f" or ((dataType == "signed" and "i" or "I") .. byteDepth))
+ local formatChunk = str_sub(format, 1, 1) .. str_sub(format, 2):rep(512)
+ local retval = ""
+ for i = 1, #data, 512 do
+ if #data < i + 512 then retval = retval .. str_pack(str_rep(format, #data % 512), table_unpack(data, i, #data))
+ else retval = retval .. str_pack(formatChunk, table_unpack(data, i, i+511)) end
+ end
+ return retval
+end
+
+---@alias speaker {playAudio: fun(samples: number[], volume?: number)}
+
+--- Plays back stream functions created by one of the `aukit.stream` functions
+--- or `Audio.stream`.
+---@param callback fun():number[][]|nil The iterator function that returns each chunk
+---@param progress? fun(pos:number) A callback to report progress to
+--- the caller; if omitted then this argument is the first speaker
+---@param volume? number The volume to play the audio at; if omitted then
+--- this argument is the second speaker (if provided)
+---@param ... speaker The speakers to play on
+function aukit.play(callback, progress, volume, ...)
+ expect(1, callback, "function")
+ expect(2, progress, "function", "table")
+ expect(3, volume, "number", "table", "nil")
+ local speakers = {...}
+ if type(volume) == "table" then
+ table_insert(speakers, 1, volume)
+ volume = nil
+ end
+ if type(progress) == "table" then
+ table_insert(speakers, 1, progress)
+ progress = nil
+ end
+ if #speakers == 0 then error("bad argument #2 (expected speakers, got nil)", 2) end
+ local chunks = {}
+ local complete = false
+ local a, b = coroutine.create(function()
+ for chunk, pos in callback do chunks[#chunks+1] = {chunk, pos} coroutine.yield(speakers) end
+ complete = true
+ end), coroutine.create(function()
+ while not complete or #chunks > 0 do
+ while not chunks[1] do if complete then return end coroutine.yield(speakers) end
+ local pchunk = table_remove(chunks, 1)
+ local fn = {}
+ if progress then progress(pchunk[2]) end
+ pchunk = pchunk[1]
+ local chunklist = {}
+ if #pchunk[1] < 96000 then chunklist = {pchunk}
+ else
+ for i = 0, #pchunk[1] - 1, 48000 do
+ local chunk = {}
+ chunklist[#chunklist+1] = chunk
+ for j = 1, #pchunk do
+ local s, c = pchunk[j], {}
+ chunk[j] = c
+ for k = 1, 48000 do c[k] = s[k+i] end
+ end
+ end
+ end
+ for _, chunk in ipairs(chunklist) do
+ for i, v in ipairs(speakers) do fn[i] = function()
+ local name = peripheral.getName(v)
+ if _HOST:find("CraftOS-PC v2.6.4") and config and not config.get("standardsMode") then
+ v.playAudio(chunk[i] or chunk[1], volume)
+ repeat until select(2, os_pullEvent("speaker_audio_empty")) == name
+ else while not v.playAudio(chunk[i] or chunk[1], volume) do
+ repeat until select(2, os_pullEvent("speaker_audio_empty")) == name
+ end end
+ end end
+ parallel.waitForAll(table_unpack(fn))
+ end
+ end
+ end)
+ local ok, af, bf
+ local aq, bq = {{}}, {{}}
+ repeat
+ if #aq > 0 then
+ local event = table_remove(aq, 1)
+ if af == speakers then
+ af = nil
+ table_insert(aq, 1, event)
+ end
+ if af == nil or event[1] == af then
+ ok, af = coroutine.resume(a, table_unpack(event, 1, event.n))
+ if not ok then error(af, 2) end
+ end
+ end
+ if #bq > 0 then
+ local event = table_remove(bq, 1)
+ if bf == speakers then
+ bf = nil
+ table_insert(bq, 1, event)
+ end
+ if bf == nil or event[1] == bf then
+ ok, bf = coroutine.resume(b, table_unpack(event, 1, event.n))
+ if not ok then error(bf, 2) end
+ end
+ end
+ if coroutine.status(b) == "suspended" and (#aq == 0 or #bq == 0) then
+ if af ~= nil and bf ~= nil then
+ local event = table_pack(os_pullEvent())
+ aq[#aq+1] = event
+ bq[#bq+1] = event
+ else
+ os_queueEvent("__queue_end")
+ while true do
+ local event = table_pack(os_pullEvent())
+ if event[1] == "__queue_end" then break end
+ aq[#aq+1] = event
+ bq[#bq+1] = event
+ end
+ end
+ end
+ until coroutine.status(b) == "dead" or complete
+ while coroutine.status(b) == "suspended" and #bq > 0 do
+ local event = table_remove(bq, 1)
+ if bf == nil or event[1] == bf then
+ ok, bf = coroutine.resume(b, table_unpack(event, 1, event.n))
+ if not ok then error(bf, 2) end
+ end
+ end
+ while coroutine.status(b) == "suspended" do
+ ok, bf = coroutine.resume(b, os_pullEvent())
+ if not ok then error(bf, 2) end
+ end
+end
+
+--- An asynchronous player created by `aukit.player`.
+---@class aukit.Player
+---@field isPaused boolean Whether the player is paused.
+---@field position number The current position of the audio, in seconds
+---@field volume number The volume of the audio
+---@field loaderTask Task The task that is loading the audio
+---@field playerTask Task The task that is playing the audio
+---@field speakers speaker[] The speakers the audio is playing on
+---@field lastPlayback {pos:number,time:number} Internal playback positions for pausing
+---@field invalidate boolean Internal flag to invalidate current chunk
+local Player = {}
+local Player_mt = {__index = Player, __name = "aukit.player"}
+
+--- Pauses the player if it's playing.
+function Player:pause()
+ if self.isPaused then return end
+ if not self.playerTask then error("Player is stopped", 2) end
+ local time = os_epoch "utc"
+ self.isPaused = true
+ self.invalidate = true
+ for _, v in ipairs(self.speakers) do v.stop() end
+ self.position = (self.lastPlayback.pos + ((time - self.lastPlayback.time) * 48) - 1) / 48000
+end
+
+--- Plays the audio if it's paused.
+function Player:play()
+ if not self.isPaused then return end
+ if not self.playerTask then error("Player is stopped", 2) end
+ self.isPaused = false
+end
+
+--- Returns the estimated current position of audio playback.
+---@return number pos The position of the audio, in seconds
+function Player:livePosition()
+ if not self.playerTask then error("Player is stopped", 2) end
+ if not self.lastPlayback then return 0 end
+ if self.isPaused then return self.position end
+ return (self.lastPlayback.pos + ((os_epoch "utc" - self.lastPlayback.time) * 48) - 1) / 48000
+end
+
+--- Seeks the current audio playback position. If the requested position isn't
+--- loaded yet, the player will hang until the audio is loaded. (If the position
+--- is out of bounds, the player will not continue.)
+---@param pos number The position to seek to, in seconds
+function Player:seek(pos)
+ expect.range(pos, 0)
+ if not self.playerTask then error("Player is stopped", 2) end
+ self.position = pos
+ self.invalidate = true
+ for _, v in ipairs(self.speakers) do v.stop() end
+end
+
+--- Stops the audio playback. This kills all tasks and invalidates the player.
+function Player:stop()
+ if not self.playerTask then error("Player is stopped", 2) end
+ self.playerTask:remove()
+ if self.loaderTask then self.loaderTask:remove() end
+ self.playerTask = nil
+ self.loaderTask = nil
+end
+
+--- Creates a player object that runs asynchronously. This requires the
+--- [Taskmaster](https://gist.github.com/MCJack123/1678fb2c240052f1480b07e9053d4537)
+--- library to function.
+---@param loop Taskmaster The Taskmaster loop to start the player on
+---@param callback fun():number[][]|nil The iterator function that returns each chunk
+---@param volume? number The volume to play the audio at; if omitted then
+--- this argument is the second speaker (if provided)
+---@param ... speaker The speakers to play on
+---@return aukit.Player player The player object to control playback with
+function aukit.player(loop, callback, volume, ...)
+ expect(1, loop, "table")
+ expect(2, callback, "function")
+ expect(3, volume, "number", "table")
+ local speakers = {...}
+ if type(volume) == "table" then
+ table_insert(speakers, 1, volume)
+ volume = nil
+ end
+ if #speakers == 0 then error("bad argument #3 (expected speakers, got nil)", 2) end
+ local player = setmetatable({
+ isPaused = false,
+ position = 0,
+ volume = volume,
+ speakers = speakers
+ }, Player_mt)
+ local decoded = {}
+ local id = tostring(player)
+ player.loaderTask = loop:addTask(function()
+ local n = 0
+ for chunk, pos in callback do
+ for j = 1, #chunk do
+ local c, d = chunk[j], decoded[j]
+ if not d then d = {} decoded[j] = d end
+ for i = 1, #c do
+ d[n+i] = c[i]
+ end
+ end
+ n = n + #chunk[1]
+ os_queueEvent("aukit.loader_next", id)
+ repeat local _, param = os_pullEvent("aukit.loader_next")
+ until param == id
+ end
+ player.loaderTask = nil
+ end)
+ player.playerTask = loop:addTask(function()
+ while true do
+ local spos = math_floor(player.position * 48000) + 1
+ if not player.isPaused and decoded[1] and decoded[1][spos] then
+ local chunk = {}
+ for j = 1, #decoded do
+ chunk[j] = {table_unpack(decoded[j], spos, math_min(spos + 47999, #decoded[j]))}
+ end
+ player.position = player.position + #chunk[1] / 48000
+ local fn = {}
+ for i, v in ipairs(speakers) do fn[i] = function()
+ local name = peripheral.getName(v)
+ if _HOST:find("CraftOS-PC v2.6.4") and config and not config.get("standardsMode") then
+ v.playAudio(chunk[i] or chunk[1], volume)
+ repeat until select(2, os_pullEvent("speaker_audio_empty")) == name
+ else while not v.playAudio(chunk[i] or chunk[1], volume) do
+ repeat until select(2, os_pullEvent("speaker_audio_empty")) == name
+ if player.invalidate then chunk = nil return end
+ end end
+ player.lastPlayback = {time = os.epoch "utc", pos = spos}
+ end end
+ parallel.waitForAll(table_unpack(fn))
+ player.invalidate = false
+ elseif not player.isPaused and player.loaderTask == nil and spos == #decoded[1] + 1 then
+ player.playerTask = nil
+ return
+ else
+ os_pullEvent()
+ end
+ end
+ end)
+ return player
+end
+
+local datafmts = {
+ {"bbbbbbbb", 8, "signed"},
+ {"BBBBBBBB", 8, "unsigned"},
+ {"hhhhhhhh", 16, "signed"},
+ {"iiiiiiii", 32, "signed"},
+ {"ffffffff", 32, "float"},
+ {"i3i3i3i3i3i3i3i3", 24, "signed"},
+ {"IIIIIIII", 32, "unsigned"},
+ {"I3I3I3I3I3I3I3I3", 24, "unsigned"},
+ {"HHHHHHHH", 16, "unsigned"},
+}
+
+--- Detect the type of audio file from the specified data. This uses heuristic
+--- detection methods to attempt to find the correct data type for files without
+--- headers. It is not recommended to rely on the data type/bit depth reported
+--- for PCM files - they are merely a suggestion.
+---@param data string The audio file to check
+---@return string|nil type The type of audio file detected, or `nil` if none could be found ("pcm"|"dfpwm"|"mdfpwm"|"wav"|"aiff"|"au"|"flac")
+---@return number|nil bitDepth The bit depth for PCM data, if the type is "pcm" and the bit depth can be detected
+---@return string|nil dataType The data type for PCM data, if the type is "pcm" and the type can be detected ("signed"|"unsigned"|"float")
+function aukit.detect(data)
+ expect(1, data, "string")
+ if data:match "^RIFF....WAVE" then return "wav"
+ elseif data:match "^FORM....AIF[FC]" then return "aiff"
+ elseif data:match "^%.snd" then return "au"
+ elseif data:match "^fLaC" then return "flac"
+ elseif data:match "^MDFPWM\3" then return "mdfpwm"
+ elseif data:match "^qoaf" then return "qoa"
+ else
+ -- Detect data type otherwise
+ -- This expects the start or end of the audio to be (near) silence
+ for _, bits in pairs(datafmts) do
+ local mid, gap = bits[3] == "unsigned" and 2^(bits[2]-1) or 0, bits[3] == "float" and 0.001 or 8 * 2^(bits[2]-8)
+ local nums = {pcall(str_unpack, bits[1], data)}
+ nums[#nums] = nil
+ if table_remove(nums, 1) then
+ local allzero, ok = true, true
+ for _, v in ipairs(nums) do
+ if v ~= mid then allzero = false end
+ if v < mid - gap or v > mid + gap then ok = false break end
+ end
+ ---@diagnostic disable-next-line return-type-mismatch
+ if ok and not allzero then return "pcm", table_unpack(bits, 2) end
+ end
+ nums = {pcall(str_unpack, bits[1], data, #data - bits[2])}
+ nums[#nums] = nil
+ if table_remove(nums, 1) then
+ local allzero, ok = true, true
+ for _, v in ipairs(nums) do
+ if v ~= mid then allzero = false end
+ if v < mid - gap or v > mid + gap then ok = false break end
+ end
+ ---@diagnostic disable-next-line return-type-mismatch
+ if ok and not allzero then return "pcm", table_unpack(bits, 2) end
+ end
+ end
+ if data:match(("\x55"):rep(12)) or data:match(("\xAA"):rep(12)) then return "dfpwm" end
+ end
+ return nil
+end
+
+--[[
+..######..########.########..########....###....##.....##
+.##....##....##....##.....##.##.........##.##...###...###
+.##..........##....##.....##.##........##...##..####.####
+..######.....##....########..######...##.....##.##.###.##
+.......##....##....##...##...##.......#########.##.....##
+.##....##....##....##....##..##.......##.....##.##.....##
+..######.....##....##.....##.########.##.....##.##.....##
+]]
+
+--- aukit.stream
+---@section aukit.stream
+
+--- Returns an iterator to stream raw PCM audio in CC format. aukit.Audio will
+--- automatically be resampled to 48 kHz, and optionally mixed down to mono. Data
+--- *must* be interleaved - this will not work with planar audio.
+---@param data string|table|function The audio data, either as a raw string, a
+--- table of values (in the format specified by `bitDepth` and `dataType`), or a
+--- function that returns either of those types. Functions will be called at
+--- least once before returning to get the type of data to use.
+---@param bitDepth? number The bit depth of the audio (8, 16, 24, 32); if `dataType` is "float" then this must be 32
+---@param dataType? "signed"|"unsigned"|"float" The type of each sample
+---@param channels? number The number of channels present in the audio
+---@param sampleRate? number The sample rate of the audio in Hertz
+---@param bigEndian? boolean Whether the audio is big-endian or little-endian; ignored if data is a table
+---@param mono? boolean Whether to mix the audio down to mono
+---@return fun():number[][]|nil,number|nil _ An iterator function that returns
+--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as
+--- the current position of the audio in seconds
+---@return number _ The total length of the audio in seconds, or the length of
+--- the first chunk if using a function
+function aukit.stream.pcm(data, bitDepth, dataType, channels, sampleRate, bigEndian, mono)
+ local fn, complete
+ if type(data) == "function" then fn, data = data, data() end
+ expect(1, data, "string", "table")
+ bitDepth = expect(2, bitDepth, "number", "nil") or 8
+ dataType = expect(3, dataType, "string", "nil") or "signed"
+ channels = expect(4, channels, "number", "nil") or 1
+ sampleRate = expect(5, sampleRate, "number", "nil") or 48000
+ expect(6, bigEndian, "boolean", "nil")
+ expect(7, mono, "boolean", "nil")
+ if bitDepth ~= 8 and bitDepth ~= 16 and bitDepth ~= 24 and bitDepth ~= 32 then error("bad argument #2 (invalid bit depth)", 2) end
+ if dataType ~= "signed" and dataType ~= "unsigned" and dataType ~= "float" then error("bad argument #3 (invalid data type)", 2) end
+ if dataType == "float" and bitDepth ~= 32 then error("bad argument #2 (float audio must have 32-bit depth)", 2) end
+ expect.range(channels, 1)
+ expect.range(sampleRate, 1)
+ if channels == 1 then mono = false end
+ local byteDepth = bitDepth / 8
+ local len = (#data / (type(data) == "table" and 1 or byteDepth)) / channels
+ local csize = jit and 7680 or 32768
+ local csizeb = csize * byteDepth
+ local bitDir = bigEndian and ">" or "<"
+ local sformat = dataType == "float" and "f" or ((dataType == "signed" and "i" or "I") .. byteDepth)
+ local format = bitDir .. str_rep(sformat, csize)
+ local maxValue = 2^(bitDepth-1)
+ local pos, spos = 1, 1
+ local tmp = {}
+ local read
+ if type(data) == "table" then
+ if dataType == "signed" then
+ function read()
+ if complete then return nil end
+ if fn and pos > #data then
+ data, pos = fn(), 1
+ if not data then complete = true return nil end
+ end
+ local s = data[pos]
+ pos = pos + 1
+ return s / (s < 0 and maxValue or maxValue-1)
+ end
+ elseif dataType == "unsigned" then
+ function read()
+ if complete then return nil end
+ if fn and pos > #data then
+ data, pos = fn(), 1
+ if not data then complete = true return nil end
+ end
+ local s = data[pos]
+ pos = pos + 1
+ return (s - 128) / (s < 128 and maxValue or maxValue-1)
+ end
+ else
+ function read()
+ if complete then return nil end
+ if fn and pos > #data then
+ data, pos = fn(), 1
+ if not data then complete = true return nil end
+ end
+ local s = data[pos]
+ pos = pos + 1
+ return s
+ end
+ end
+ elseif dataType == "float" then
+ function read()
+ if complete then return nil end
+ if pos > #tmp then
+ if fn and spos > #data then
+ data, spos = fn(), 1
+ if not data then complete = true return nil end
+ end
+ if spos + csizeb > #data then
+ local f = bitDir .. str_rep(sformat, (#data - spos + 1) / byteDepth)
+ tmp = {str_unpack(f, data, spos)}
+ spos = tmp[#tmp]
+ tmp[#tmp] = nil
+ else
+ tmp = {str_unpack(format, data, spos)}
+ spos = tmp[#tmp]
+ tmp[#tmp] = nil
+ end
+ pos = 1
+ end
+ local s = tmp[pos]
+ pos = pos + 1
+ return s
+ end
+ elseif dataType == "signed" then
+ function read()
+ if complete then return nil end
+ if pos > #tmp then
+ if fn and spos > #data then
+ data, spos = fn(), 1
+ if not data then complete = true return nil end
+ end
+ if spos + csizeb > #data then
+ local f = bitDir .. str_rep(sformat, (#data - spos + 1) / byteDepth)
+ tmp = {str_unpack(f, data, spos)}
+ spos = tmp[#tmp]
+ tmp[#tmp] = nil
+ else
+ tmp = {str_unpack(format, data, spos)}
+ spos = tmp[#tmp]
+ tmp[#tmp] = nil
+ end
+ pos = 1
+ end
+ local s = tmp[pos]
+ pos = pos + 1
+ return s / (s < 0 and maxValue or maxValue-1)
+ end
+ else -- unsigned
+ function read()
+ if complete then return nil end
+ if pos > #tmp then
+ if fn and spos > #data then
+ data, spos = fn(), 1
+ if not data then complete = true return nil end
+ end
+ if spos + csizeb > #data then
+ local f = bitDir .. str_rep(sformat, (#data - spos + 1) / byteDepth)
+ tmp = {str_unpack(f, data, spos)}
+ spos = tmp[#tmp]
+ tmp[#tmp] = nil
+ else
+ tmp = {str_unpack(format, data, spos)}
+ spos = tmp[#tmp]
+ tmp[#tmp] = nil
+ end
+ pos = 1
+ end
+ local s = tmp[pos]
+ pos = pos + 1
+ return (s - 128) / (s < 128 and maxValue or maxValue-1)
+ end
+ end
+ local d = {}
+ local ratio = 48000 / sampleRate
+ local lp_alpha = 1 - math.exp(-(sampleRate / 96000) * 2 * math_pi)
+ local interp = interpolate[aukit.defaultInterpolation]
+ for j = 1, (mono and 1 or channels) do d[j] = setmetatable({}, {__index = function(self, i)
+ if mono then for _ = 1, channels do self[i] = (rawget(self, i) or 0) + read() end self[i] = self[i] / channels
+ else self[i] = read() end
+ return rawget(self, i)
+ end}) end
+ local n = 0
+ local ok = true
+ return function()
+ if not ok or complete then return nil end
+ for i = (n == 0 and interpolation_start[aukit.defaultInterpolation] or 1), interpolation_end[aukit.defaultInterpolation] do
+ if mono then
+ local s = 0
+ for j = 1, channels do
+ local c = read()
+ if not c then return nil end
+ s = s + c
+ end
+ d[1][i] = s / channels
+ else for j = 1, channels do d[j][i] = read() if not d[j][i] then return nil end end end
+ end
+ local chunk = {}
+ for j = 1, #d do chunk[j] = {} end
+ ok = pcall(function()
+ local ls = {}
+ for y = 1, #d do
+ local s = chunk[y][0] or 0
+ ls[y] = s / (s < 0 and 128 or 127)
+ end
+ for i = 1, 48000 do
+ for y = 1, #d do
+ local x = ((i - 1) / ratio) + 1
+ local s
+ if x % 1 == 0 then s = d[y][x]
+ else s = interp(d[y], x) end
+ local ns = ls[y] + lp_alpha * (s - ls[y])
+ chunk[y][i] = clamp(ns * (ns < 0 and 128 or 127), -128, 127)
+ ls[y] = s
+ end
+ end
+ end)
+ if #chunk[1] == 0 then return nil end
+ n = n + #chunk[1]
+ for y = 1, #d do
+ if aukit.defaultInterpolation == "sinc" then
+ local t, l = {}, #d[y]
+ for i = -sincWindowSize, 0 do
+ t[i] = d[y][l + i]
+ end
+ d[y] = setmetatable(t, getmetatable(d[y]))
+ else
+ local l2, l1 = d[y][#d[y]-1], d[y][#d[y]]
+ d[y] = setmetatable({}, getmetatable(d[y]))
+ d[y][-1], d[y][0] = l2, l1
+ end
+ end
+ return chunk, (n - #chunk[1]) / 48000
+ end, len / sampleRate
+end
+
+--- Returns an iterator to stream audio from DFPWM data. aukit.Audio will automatically
+--- be resampled to 48 kHz. Multiple channels are expected to be interleaved in
+--- the encoded DFPWM data.
+---@param data string|fun():string The DFPWM data to decode, or a function
+--- returning chunks to decode
+---@param sampleRate? number The sample rate of the audio in Hertz
+---@param channels? number The number of channels present in the audio
+---@param mono? boolean Whether to mix the audio down to mono
+---@return fun():number[][]|nil,number|nil _ An iterator function that returns
+--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as
+--- the current position of the audio in seconds
+---@return number|nil _ The total length of the audio in seconds, or nil if data
+--- is a function
+function aukit.stream.dfpwm(data, sampleRate, channels, mono)
+ expect(1, data, "string", "function")
+ sampleRate = expect(2, sampleRate, "number", "nil") or 48000
+ channels = expect(3, channels, "number", "nil") or 1
+ expect.range(sampleRate, 1)
+ expect.range(channels, 1)
+ if channels == 1 then mono = false end
+ local decoder = dfpwm.make_decoder()
+ local pos = 1
+ local last = 0
+ local isstr = type(data) == "string"
+ local buf = ""
+ return function()
+ local d
+ if isstr then
+ if pos > #data then return nil end
+ d = str_sub(data, pos, pos + 6000 * channels)
+ else
+ while #buf < sampleRate / 8 * channels do
+ local chunk = data()
+ if not chunk then
+ if #buf == 0 then return nil
+ else break end
+ end
+ buf = buf .. chunk
+ end
+ d = str_sub(buf, 1, 6000 * channels)
+ buf = str_sub(buf, 6000 * channels + 1)
+ end
+ local audio = decoder(d)
+ if audio == nil or #audio == 0 then return nil end
+ audio[0], last = last, audio[#audio]
+ os_queueEvent("nosleep")
+ repeat until "nosleep" == os_pullEvent()
+ local ratio = 48000 / sampleRate
+ local newlen = #audio * ratio
+ local interp = interpolate[aukit.defaultInterpolation]
+ local lines = {{}}
+ if not mono then for j = 1, channels do lines[j] = {} end end
+ for i = 1, newlen, channels do
+ local n = 0
+ for j = 1, channels do
+ local x = (i - 1) / ratio + 1
+ local s
+ if x % 1 == 0 then s = audio[x]
+ else s = clamp(interp(audio, x), -128, 127) end
+ if mono then n = n + s
+ else lines[j][math_ceil(i / channels)] = s end
+ end
+ if mono then lines[1][math_ceil(i / channels)] = n / channels end
+ end
+ os_queueEvent("nosleep")
+ repeat until "nosleep" == os_pullEvent()
+ local p = pos
+ pos = pos + 6000 * channels
+ return lines, p * 8 / sampleRate / channels
+ end, isstr and #data * 8 / sampleRate / channels or nil
+end
+
+--- Returns an iterator to stream audio from MDFPWMv3 data.
+---@param data string|fun():string The MDFPWM data to decode, or a function
+--- returning chunks to decode
+---@param mono? boolean Whether to mix the audio down to mono
+---@return fun():number[][]|nil,number|nil _ An iterator function that returns
+--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as
+--- the current position of the audio in seconds
+---@return number|nil _ The total length of the audio in seconds, or nil if data
+--- is a function
+function aukit.stream.mdfpwm(data, mono)
+ expect(1, data, "string", "function")
+ local decoderL, decoderR = dfpwm.make_decoder(), dfpwm.make_decoder()
+ local isstr = type(data) == "string"
+ local pos = 1
+ local headerSize = 0
+ local length
+ local buf = ""
+ local _
+ if isstr then
+ if data:sub(1, 7) ~= "MDFPWM\3" then error("bad argument #1 (invalid MDFPWM data)", 2) end
+ length, _, _, _, pos = str_unpack(" #data then return nil end
+ dL = str_sub(data, pos, pos + 5999)
+ dR = str_sub(data, pos + 6000, pos + 11999)
+ else
+ while #buf < 12000 do
+ local chunk = data()
+ if not chunk then
+ if #buf == 0 then return nil
+ else break end
+ end
+ buf = buf .. chunk
+ end
+ dL = str_sub(buf, 1, 6000)
+ dR = str_sub(buf, 6001, 12000)
+ buf = str_sub(buf, 12001)
+ end
+ local audioL = decoderL(dL)
+ if audioL == nil or #audioL == 0 then return nil end
+ local audioR = decoderR(dR)
+ if audioR == nil or #audioR == 0 then return nil end
+ os_queueEvent("nosleep")
+ repeat until "nosleep" == os_pullEvent()
+ if pos - headerSize + 12000 > length then
+ for i = (length / 2) % 6000 + 1, 6000 do
+ audioL[i], audioR[i] = nil
+ end
+ end
+ local lines
+ if mono then
+ local l = {}
+ lines = {l}
+ for i = 1, 48000 do
+ l[i] = clamp(math_floor(audioL[i] + audioR[i] / 2), -128, 127)
+ end
+ else lines = {audioL, audioR} end
+ os_queueEvent("nosleep")
+ repeat until "nosleep" == os_pullEvent()
+ local p = pos - headerSize
+ pos = pos + #dL + #dR
+ return lines, p / 12000
+ end, length / 12000
+end
+
+--- Returns an iterator to stream audio from Microsoft ADPCM data. aukit.Audio will
+--- automatically be resampled to 48 kHz.
+---@param input string|fun():string The audio data as a raw string or
+--- reader function
+---@param blockAlign number The number of bytes in each block
+---@param channels? number The number of channels present in the audio
+---@param sampleRate? number The sample rate of the audio in Hertz
+---@param mono? boolean Whether to mix the audio down to mono
+---@param coefficients? table Two lists of coefficients to use
+---@return fun():number[][]|nil,number|nil _ An iterator function that returns
+--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as
+--- the current position of the audio in seconds
+---@return number|nil _ The total length of the audio in seconds, or nil if data
+--- is a function
+function aukit.stream.msadpcm(input, blockAlign, channels, sampleRate, mono, coefficients)
+ expect(1, input, "string", "function")
+ expect(2, blockAlign, "number")
+ channels = expect(3, channels, "number", "nil") or 1
+ sampleRate = expect(4, sampleRate, "number", "nil") or 48000
+ expect(5, mono, "boolean", "nil")
+ expect(6, coefficients, "table", "nil")
+ expect.range(sampleRate, 1)
+ local isfunc = type(input) == "function"
+ local coeff1, coeff2
+ if coefficients then
+ if type(coefficients[1]) ~= "table" then error("bad argument #5 (first entry is not a table)", 2) end
+ if type(coefficients[2]) ~= "table" then error("bad argument #5 (second entry is not a table)", 2) end
+ if #coefficients[1] ~= #coefficients[2] then error("bad argument #5 (lists are not the same length)", 2) end
+ coeff1, coeff2 = {}, {}
+ for i, v in ipairs(coefficients[1]) do
+ if type(v) ~= "number" then error("bad entry #" .. i .. " in coefficient list 1 (expected number, got " .. type(v) .. ")", 2) end
+ coeff1[i-1] = v
+ end
+ for i, v in ipairs(coefficients[2]) do
+ if type(v) ~= "number" then error("bad entry #" .. i .. " in coefficient list 2 (expected number, got " .. type(v) .. ")", 2) end
+ coeff2[i-1] = v
+ end
+ else coeff1, coeff2 = {[0] = 256, 512, 0, 192, 240, 460, 392}, {[0] = 0, -256, 0, 64, 0, -208, -232} end
+ local ratio = 48000 / sampleRate
+ local interp = interpolate[aukit.defaultInterpolation]
+ local n, pos = 1, 0
+ local data = isfunc and input() or input
+ if channels == 2 then
+ local samplesPerBlock = blockAlign - 14
+ local iterPerSecond = math_ceil(sampleRate / samplesPerBlock)
+ local bytesPerSecond = blockAlign * iterPerSecond
+ local newlen = math_floor(samplesPerBlock * ratio)
+ local lastL, lastR
+ return function()
+ if data == nil then return nil end
+ local target = n + bytesPerSecond
+ local retval = {{}, not mono and {} or nil}
+ local rp = 0
+ local start = os_epoch "utc"
+ while n < target do
+ if os_epoch "utc" - start > 3000 then
+ os_queueEvent("nosleep")
+ repeat until "nosleep" == os_pullEvent()
+ start = os_epoch "utc"
+ end
+ if isfunc and n > #data then
+ pos = pos + #data
+ n = n - #data
+ data = input()
+ if data == nil then return nil end
+ end
+ if n > #data then break end
+ local left, right = {}, {}
+ if lastL then for i = 1, #lastL do
+ left[i-#lastL-1] = lastL[i]
+ right[i-#lastR-1] = lastR[i]
+ end end
+ local predictorIndexL, predictorIndexR, deltaL, deltaR, sample1L, sample1R, sample2L, sample2R = str_unpack("= 8 then hi = hi - 16 end
+ if lo >= 8 then lo = lo - 16 end
+ local predictor = clamp(math_floor((sample1L * c1L + sample2L * c2L) / 256) + hi * deltaL, -32768, 32767)
+ left[#left+1] = math_floor(predictor / (predictor < 0 and 128 or 127))
+ sample2L, sample1L = sample1L, predictor
+ deltaL = math_max(math_floor(msadpcm_adaption_table[hi] * deltaL / 256), 16)
+ predictor = clamp(math_floor((sample1R * c1R + sample2R * c2R) / 256) + lo * deltaR, -32768, 32767)
+ right[#right+1] = math_floor(predictor / (predictor < 0 and 128 or 127))
+ sample2R, sample1R = sample1R, predictor
+ deltaR = math_max(math_floor(msadpcm_adaption_table[lo] * deltaR / 256), 16)
+ end
+ lastL, lastR = left, right
+ for i = 1, newlen do
+ local x = (i - 1) / ratio + 1
+ local l, r
+ if x % 1 == 0 then l, r = left[x], right[x]
+ else l, r = interp(left, x), interp(right, x) end
+ if mono then retval[1][rp+i] = clamp(math_floor(l + r / 2), -128, 127)
+ else retval[1][rp+i], retval[2][rp+i] = clamp(math_floor(l), -128, 127), clamp(math_floor(r), -128, 127) end
+ end
+ rp = rp + newlen
+ n = n + blockAlign
+ end
+ if #retval[1] == 0 then return nil end
+ return retval, (n + pos) / bytesPerSecond
+ end, not isfunc and #data / blockAlign * samplesPerBlock / sampleRate or nil
+ elseif channels == 1 then
+ local samplesPerBlock = (blockAlign - 7) * 2
+ local iterPerSecond = math_ceil(sampleRate / samplesPerBlock)
+ local bytesPerSecond = blockAlign * iterPerSecond
+ local newlen = math_floor(samplesPerBlock * ratio)
+ return function()
+ if data == nil then return nil end
+ local target = n + bytesPerSecond
+ local retval = {{}}
+ local rp = 0
+ local start = os_epoch "utc"
+ while n < target do
+ if os_epoch "utc" - start > 3000 then
+ os_queueEvent("nosleep")
+ repeat until "nosleep" == os_pullEvent()
+ start = os_epoch "utc"
+ end
+ if isfunc and n > #data then
+ pos = pos + #data
+ n = n - #data
+ data = input()
+ if data == nil then return nil end
+ end
+ if n > #data then break end
+ local left = {}
+ local predictorIndex, delta, sample1, sample2 = str_unpack("= 8 then hi = hi - 16 end
+ if lo >= 8 then lo = lo - 16 end
+ local predictor = clamp(math_floor((sample1 * c1 + sample2 * c2) / 256) + hi * delta, -32768, 32767)
+ left[#left+1] = predictor / (predictor < 0 and 128 or 127)
+ sample2, sample1 = sample1, predictor
+ delta = math_max(math_floor(msadpcm_adaption_table[hi] * delta / 256), 16)
+ predictor = clamp(math_floor((sample1 * c1 + sample2 * c2) / 256) + lo * delta, -32768, 32767)
+ left[#left+1] = predictor / (predictor < 0 and 128 or 127)
+ sample2, sample1 = sample1, predictor
+ delta = math_max(math_floor(msadpcm_adaption_table[lo] * delta / 256), 16)
+ end
+ for i = 1, newlen do
+ local x = (i - 1) / ratio + 1
+ if x % 1 == 0 then retval[1][rp+i] = clamp(math_floor(left[x]), -128, 127)
+ else retval[1][rp+i] = clamp(math_floor(interp(left, x)), -128, 127) end
+ end
+ rp = rp + newlen
+ n = n + blockAlign
+ end
+ if #retval[1] == 0 then return nil end
+ return retval, (n + pos) / bytesPerSecond
+ end, not isfunc and #data / blockAlign * samplesPerBlock / sampleRate or nil
+ else error("Unsupported number of channels: " .. channels) end
+end
+
+--- Returns an iterator to stream data from IMA ADPCM data. aukit.Audio will
+--- automatically be resampled to 48 kHz, and mixed to mono if desired. Data
+--- *must* be in the interleaving format used in WAV files (i.e. periodic blocks
+--- with 4/8-byte headers, channels alternating every 4 bytes, lower nibble first).
+---@param input string|fun():string The audio data as a raw string or
+--- reader function
+---@param blockAlign number The number of bytes in each block
+---@param channels? number The number of channels present in the audio
+---@param sampleRate? number The sample rate of the audio in Hertz
+---@param mono? boolean Whether to mix the audio down to mono
+---@return fun():number[][]|nil,number|nil _ An iterator function that returns
+--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as
+--- the current position of the audio in seconds
+---@return number|nil _ The total length of the audio in seconds, or nil if data
+--- is a function
+function aukit.stream.adpcm(input, blockAlign, channels, sampleRate, mono)
+ expect(1, input, "string", "function")
+ expect(2, blockAlign, "number")
+ channels = expect(3, channels, "number", "nil") or 1
+ sampleRate = expect(4, sampleRate, "number", "nil") or 48000
+ expect(5, mono, "boolean", "nil")
+ expect.range(sampleRate, 1)
+ local isfunc = type(input) == "function"
+ local ratio = 48000 / sampleRate
+ local interp = interpolate[aukit.defaultInterpolation]
+ local n, pos = 1, 0
+ local data = isfunc and input() or input
+ local samplesPerBlock = (blockAlign - 4 * channels) * 2 / channels
+ local iterPerSecond = math_ceil(sampleRate / samplesPerBlock)
+ local bytesPerSecond = blockAlign * iterPerSecond
+ local newlen = math_floor(samplesPerBlock * ratio)
+ local last
+ return function()
+ if data == nil then return nil end
+ local target = n + bytesPerSecond
+ local retval = {{}}
+ if not mono then for i = 2, channels do retval[i] = {} end end
+ local rp = 0
+ if isfunc and target > #data then
+ pos = pos + n - 1
+ target = target - n + 1
+ data = str_sub(data, n)
+ n = 1
+ while #data < target do
+ local d = input()
+ if not d then break end
+ data = data .. d
+ end
+ end
+ local start = os_epoch "utc"
+ while n < target do
+ if os_epoch "utc" - start > 3000 then
+ os_queueEvent("nosleep")
+ repeat until "nosleep" == os_pullEvent()
+ start = os_epoch "utc"
+ end
+ if n + channels * 4 > #data then break end
+ local d = {}
+ for i = 1, channels do d[i] = {} end
+ if last then for i = 1, channels do for j = 1, #last[i] do d[j-#last[i]-1] = last[i][j] end end end
+ local predictor, step_index, step = {}, {}, {}
+ for i = 1, channels do predictor[i], step_index[i] = str_unpack("= 8 then predictor[j] = clamp(predictor[j] - diff, -32768, 32767)
+ else predictor[j] = clamp(predictor[j] + diff, -32768, 32767) end
+ d[j][p+k] = predictor[j] / (predictor[j] < 0 and 128 or 127)
+ end
+ end
+ end
+ last = d
+ if #d[1] < samplesPerBlock then newlen = math_floor(#d[1] * ratio) end
+ for i = 1, newlen do
+ local x = (i - 1) / ratio + 1
+ local c = {}
+ if x % 1 == 0 then for j = 1, channels do c[j] = d[j][x] end
+ else for j = 1, channels do c[j] = interp(d[j], x) end end
+ if mono then
+ local n = 0
+ for j = 1, channels do n = n + c[j] end
+ retval[1][rp+i] = clamp(math_floor(n / channels), -128, 127)
+ else for j = 1, channels do retval[j][rp+i] = clamp(math_floor(c[j]), -128, 127) end end
+ end
+ rp = rp + newlen
+ n = n + blockAlign
+ end
+ if #retval[1] == 0 then return nil end
+ return retval, (n + pos) / bytesPerSecond
+ end, not isfunc and #data / blockAlign * samplesPerBlock / sampleRate or nil
+end
+
+--- Returns an iterator to stream data from u-law/A-law G.711 data. aukit.Audio will
+--- automatically be resampled to 48 kHz, and mixed to mono if desired.
+---@param input string|fun():string The audio data as a raw string or
+--- reader function
+---@param ulaw boolean Whether the audio uses u-law (true) or A-law (false).
+---@param channels? number The number of channels present in the audio
+---@param sampleRate? number The sample rate of the audio in Hertz
+---@param mono? boolean Whether to mix the audio down to mono
+---@return fun():number[][]|nil,number|nil _ An iterator function that returns
+--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as
+--- the current position of the audio in seconds
+---@return number|nil _ The total length of the audio in seconds, or nil if data
+--- is a function
+function aukit.stream.g711(input, ulaw, channels, sampleRate, mono)
+ expect(1, input, "string", "function")
+ expect(2, ulaw, "boolean")
+ channels = expect(3, channels, "number", "nil") or 1
+ sampleRate = expect(4, sampleRate, "number", "nil") or 8000
+ expect(5, mono, "boolean", "nil")
+ local csize = jit and 7680 or 32768
+ local xor = ulaw and 0xFF or 0x55
+ local isfunc = type(input) == "function"
+ local buf, pos = "", 1
+ local ratio = 48000 / sampleRate
+ local interp = interpolate[aukit.defaultInterpolation]
+ local last
+ return function()
+ local lp = pos
+ local retval = {}
+ for i = 1, channels do retval[i] = {} end
+ if last then for i = 1, channels do for j = 1, #last[i] do retval[j-#last[i]-1] = last[i][j] end end end
+ local data
+ if isfunc then
+ while #buf < sampleRate * channels do
+ local d = input()
+ if not input then
+ if #buf == 0 then return nil
+ else break end
+ end
+ buf = buf .. d
+ end
+ data = str_sub(buf, 1, sampleRate * channels)
+ buf = str_sub(buf, sampleRate * channels + 1)
+ else data = str_sub(input, pos, pos + sampleRate * channels - 1) end
+ pos = pos + sampleRate * channels
+ local start = os_epoch "utc"
+ for i = 1, #data, csize do
+ local bytes = {str_byte(data, i, i + csize - 1)}
+ for j = 1, #bytes do
+ local b = bit32_bxor(bytes[j], xor)
+ local m, e = bit32_band(b, 0x0F), bit32_extract(b, 4, 3)
+ if not ulaw and e == 0 then m = m * 4 + 2
+ else m = bit32_lshift(m * 2 + 33, e) end
+ if ulaw then m = m - 33 end
+ retval[(i+j-2) % channels + 1][math_floor((i+j-2) / channels + 1)] = m / (bit32_btest(b, 0x80) == ulaw and -0x40 or 0x40)
+ end
+ if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end
+ end
+ last = {}
+ for j = 1, channels do last[j] = {} for i = 1, sincWindowSize do last[j][i] = retval[j][#retval[j]-30+i] end end
+ local newlen = math_floor(#retval[1] * ratio)
+ local resamp = {}
+ for j = 1, channels do resamp[j] = {} end
+ for i = 1, newlen do
+ local x = (i - 1) / ratio + 1
+ local c = {}
+ if x % 1 == 0 then for j = 1, channels do c[j] = retval[j][x] end
+ else for j = 1, channels do c[j] = interp(retval[j], x) end end
+ if mono then
+ local n = 0
+ for j = 1, channels do n = n + c[j] end
+ resamp[1][i] = clamp(math_floor(n / channels), -128, 127)
+ else for j = 1, channels do resamp[j][i] = clamp(math_floor(c[j]), -128, 127) end end
+ end
+ return resamp, (lp - 1) / sampleRate / channels
+ end, not isfunc and #input / sampleRate / channels or nil
+end
+
+--- Returns an iterator to stream audio from a WAV file. aukit.Audio will automatically
+--- be resampled to 48 kHz, and optionally mixed down to mono. This accepts PCM
+--- files up to 32 bits, including float data, as well as DFPWM files [as specified here](https://gist.github.com/MCJack123/90c24b64c8e626c7f130b57e9800962c).
+---@param data string|fun():string The WAV file to decode, or a function
+--- returning chunks to decode (the first chunk MUST contain the ENTIRE header)
+---@param mono? boolean Whether to mix the audio to mono
+---@param ignoreHeader? boolean Whether to ignore additional headers
+--- if they appear later in the audio stream
+---@return fun():number[][]|nil,number|nil _ An iterator function that returns
+--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as
+--- the current position of the audio in seconds
+---@return number|nil _ The total length of the audio in seconds
+function aukit.stream.wav(data, mono, ignoreHeader)
+ local fn
+ if type(data) == "function" then fn, data = data, data() end
+ expect(1, data, "string")
+ local channels, sampleRate, bitDepth, length, dataType, blockAlign, coefficients
+ local temp, pos = str_unpack("c4", data)
+ if temp ~= "RIFF" then error("bad argument #1 (not a WAV file)", 2) end
+ pos = pos + 4
+ temp, pos = str_unpack("c4", data, pos)
+ if temp ~= "WAVE" then error("bad argument #1 (not a WAV file)", 2) end
+ while pos <= #data do
+ local size
+ temp, pos = str_unpack("c4", data, pos)
+ size, pos = str_unpack(" 0 then
+ coefficients = {{}, {}}
+ for i = 1, numcoeff do
+ coefficients[1][i], coefficients[2][i] = str_unpack("I", data, pos)
+ if temp == "COMM" then
+ local e, m
+ channels, length, bitDepth, e, m, pos = str_unpack(">hIhHI7x", data, pos)
+ if isAIFC then
+ local s
+ compression, s, pos = str_unpack(">c4s1", data, pos)
+ if #s % 2 == 0 then pos = pos + 1 end
+ end
+ length = length * channels * math_floor(bitDepth / 8)
+ local s = bit32_btest(e, 0x8000)
+ e = ((bit32_band(e, 0x7FFF) - 0x3FFE) % 0x800)
+ sampleRate = math.ldexp(m * (s and -1 or 1) / 0x100000000000000, e)
+ elseif temp == "SSND" then
+ offset, blockAlign, pos = str_unpack(">II", data, pos)
+ local data = str_sub(data, pos + offset, pos + offset + length - 1)
+ if not fn and #data < length then error("invalid AIFF file", 2) end
+ if fn then
+ local first, f = data
+ data = function()
+ if first then f, first = first return f
+ elseif ignoreHeader then
+ local d = fn()
+ if not d then return nil end
+ if d:match "^FORM....AIF[FC]" then
+ local n, p = d:match("^FORM....AIF[FC].-SSND(....)....()")
+ offset = str_unpack(">I", n)
+ return str_sub(d, p + offset)
+ else return d end
+ else return fn() end
+ end
+ end
+ if compression == nil or compression == "NONE" then return aukit.stream.pcm(data, bitDepth, "signed", channels, sampleRate, true, mono), length / channels / (bitDepth / 8) / sampleRate
+ elseif compression == "sowt" then return aukit.stream.pcm(data, bitDepth, "signed", channels, sampleRate, true, mono), length / channels / (bitDepth / 8) / sampleRate
+ elseif compression == "fl32" or compression == "FL32" then return aukit.stream.pcm(data, 32, "float", channels, sampleRate, true, mono), length / channels / 4 / sampleRate
+ elseif compression == "alaw" or compression == "ulaw" or compression == "ALAW" or compression == "ULAW" then return aukit.stream.g711(data, compression == "ulaw" or compression == "ULAW", channels, sampleRate, mono), length / channels / sampleRate
+ else error("Unsupported compression scheme " .. compression, 2) end
+ return aukit.stream.pcm(data, bitDepth, "signed", channels, sampleRate, true, mono), length / channels / (bitDepth / 8) / sampleRate
+ else pos = pos + size end
+ end
+ error("invalid AIFF file", 2)
+end
+
+--- Returns an iterator to stream data from an AU file. aukit.Audio will automatically
+--- be resampled to 48 kHz, and optionally mixed down to mono.
+---@param data string|fun():string The AU file to decode, or a function
+--- returning chunks to decode (the first chunk MUST contain the ENTIRE header)
+---@param mono? boolean Whether to mix the audio to mono
+---@param ignoreHeader? boolean Whether to ignore additional headers
+--- if they appear later in the audio stream
+---@return fun():number[][]|nil,number|nil _ An iterator function that returns
+--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as
+--- the current position of the audio in seconds
+---@return number _ The total length of the audio in seconds
+function aukit.stream.au(data, mono, ignoreHeader)
+ local fn
+ if type(data) == "function" then fn, data = data, data() end
+ expect(1, data, "string")
+ expect(2, mono, "boolean", "nil")
+ local magic, offset, size, encoding, sampleRate, channels = str_unpack(">c4IIIII", data)
+ if magic ~= ".snd" then error("invalid AU file", 2) end
+ if fn then
+ local first, f = str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil), nil
+ data = function()
+ if first then f, first = first return f
+ elseif ignoreHeader then
+ local d = fn()
+ if not d then return nil end
+ if d:match "^.snd" then return str_sub(d, str_unpack(">I", str_sub(d, 5, 8)), nil)
+ else return d end
+ else return fn() end
+ end
+ else data = str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil) end
+ if encoding == 1 then return aukit.stream.g711(data, true, channels, sampleRate, mono), size / channels / sampleRate
+ elseif encoding == 2 then return aukit.stream.pcm(data, 8, "signed", channels, sampleRate, true, mono), size / channels / sampleRate
+ elseif encoding == 3 then return aukit.stream.pcm(data, 16, "signed", channels, sampleRate, true, mono), size / channels / 2 / sampleRate
+ elseif encoding == 4 then return aukit.stream.pcm(data, 24, "signed", channels, sampleRate, true, mono), size / channels / 3 / sampleRate
+ elseif encoding == 5 then return aukit.stream.pcm(data, 32, "signed", channels, sampleRate, true, mono), size / channels / 4 / sampleRate
+ elseif encoding == 6 then return aukit.stream.pcm(data, 32, "float", channels, sampleRate, true, mono), size / channels / 4 / sampleRate
+ elseif encoding == 27 then return aukit.stream.g711(data, false, channels, sampleRate, mono), size / channels / sampleRate
+ else error("unsupported encoding type " .. encoding, 2) end
+end
+
+--- Returns an iterator to stream data from a FLAC file. aukit.Audio will automatically
+--- be resampled to 48 kHz, and optionally mixed down to mono.
+---@param data string|fun():string The FLAC file to decode, or a function
+--- returning chunks to decode
+---@param mono? boolean Whether to mix the audio to mono
+---@return fun():number[][]|nil,number|nil _ An iterator function that returns
+--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as
+--- the current position of the audio in seconds
+---@return number _ The total length of the audio in seconds
+function aukit.stream.flac(data, mono)
+ expect(1, data, "string", "function")
+ expect(2, mono, "boolean", "nil")
+ local infn = false
+ if type(data) == "function" then data = setmetatable({str = "", fn = data, final = false, byte = function(self, start, e)
+ while start > #self.str do
+ infn = true
+ local d = self.fn()
+ infn = false
+ if not d then self.final = true return nil end
+ self.str = self.str .. d
+ end
+ while e and e > #self.str do
+ infn = true
+ local d = self.fn()
+ infn = false
+ if not d then self.final = true return nil end
+ self.str = self.str .. d
+ end
+ return str_byte(self.str, start, e)
+ end}, {__len = function(self) return self.final and #self.str or math.huge end}) end
+ local function saferesume(coro, ...)
+ local res = table_pack(coroutine.resume(coro, ...))
+ while res[1] and infn do res = table_pack(coroutine.resume(coro, coroutine.yield(table_unpack(res, 2, res.n)))) end
+ return table_unpack(res, 1, res.n)
+ end
+ local coro = coroutine.create(decodeFLAC)
+ local ok, sampleRate, len = saferesume(coro, data, coroutine.yield)
+ if not ok then error(sampleRate, 2) end
+ local pos = 0
+ local ratio = 48000 / sampleRate
+ local lp_alpha = 1 - math.exp(-(sampleRate / 96000) * 2 * math_pi)
+ local interp = interpolate[aukit.defaultInterpolation]
+ local last = {0, 0}
+ return function()
+ if coroutine.status(coro) == "dead" then return nil end
+ local chunk = {{}}
+ while #chunk[1] < sampleRate do
+ local ok, res = saferesume(coro)
+ if not ok or res == nil or res.sampleRate then break end
+ os_queueEvent("nosleep")
+ repeat until "nosleep" == os_pullEvent()
+ for c = 1, #res do
+ chunk[c] = chunk[c] or {}
+ local src, dest = res[c], chunk[c]
+ local start = #dest
+ src[0] = last[2]
+ src[-1] = last[1]
+ local ls = last[2] / (last[2] < 0 and 128 or 127)
+ for i = 1, math_floor(#src * ratio) do
+ local d = start+i
+ local x = ((i - 1) / ratio) + 1
+ local s
+ if x % 1 == 0 then s = src[x]
+ else s = interp(src, x) end
+ s = ls + lp_alpha * (s - ls)
+ ls = s
+ dest[d] = clamp(s * (s < 0 and 128 or 127), -128, 127)
+ end
+ last = {src[#src-1], src[#src]}
+ end
+ os_queueEvent("nosleep")
+ repeat until "nosleep" == os_pullEvent()
+ end
+ pos = pos + #chunk[1] / 48000
+ return chunk, pos
+ end, len / sampleRate
+end
+
+--- Returns an iterator to stream data from a QOA file. aukit.Audio will automatically
+--- be resampled to 48 kHz, and optionally mixed down to mono.
+---@param data string|fun():string The QOA file to decode, or a function
+--- returning chunks to decode
+---@param mono? boolean Whether to mix the audio to mono
+---@return fun():number[][]|nil,number|nil _ An iterator function that returns
+--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as
+--- the current position of the audio in seconds
+---@return number _ The total length of the audio in seconds
+function aukit.stream.qoa(data, mono)
+ expect(1, data, "string", "function")
+ expect(2, mono, "boolean", "nil")
+ local read, peek
+ if type(data) == "string" then
+ local pos = 1
+ function read(n)
+ if pos > #data then return nil end
+ local d = data:sub(pos, pos + n - 1)
+ pos = pos + n
+ return d
+ end
+ function peek(n)
+ if pos > #data then return nil end
+ return data:sub(pos, pos + n - 1)
+ end
+ else
+ local buf = ""
+ function read(n)
+ while #buf < n do
+ local d = data()
+ if not d then return nil end
+ buf = buf .. d
+ end
+ local d = buf:sub(1, n)
+ buf = buf:sub(n + 1)
+ return d
+ end
+ function peek(n)
+ while #buf < n do
+ local d = data()
+ if not d then return nil end
+ buf = buf .. d
+ end
+ return buf:sub(1, n)
+ end
+ end
+ local magic, file_samples = (">c4I4"):unpack(assert(read(8), "Not a QOA file"), nil)
+ if magic ~= "qoaf" then error("Not a QOA file", 2) end
+ local file_channels, file_sampleRate = (">BI3"):unpack(assert(peek(4), "Not a QOA file"), nil)
+ local lms = {}
+ local last = {}
+ for i = 1, file_channels do
+ lms[i] = {history = {}, weights = {}}
+ last[i] = {0, 0}
+ end
+
+ local file_pos = 0
+ local ratio = 48000 / file_sampleRate
+ local lp_alpha = 1 - math.exp(-(file_sampleRate / 96000) * 2 * math_pi)
+ local interp = interpolate[aukit.defaultInterpolation]
+ return function()
+ local chunk = {}
+ for i = 1, file_channels do chunk[i] = {[-1] = last[i][1], [0] = last[i][2]} end
+ local sample_pos = 0
+ while sample_pos < file_sampleRate do
+ -- from https://github.com/phoboslab/qoa/blob/master/qoa.h
+ -- MIT license - Copyright (c) 2023 Dominic Szablewski
+
+ -- Read and verify the frame header
+ local d = read(8)
+ if not d then break end
+ local channels, samplerate, samples, frame_size = (">BI3I2I2"):unpack(d)
+
+ local data_size = frame_size - 8 - 4 * 4 * channels
+ local num_slices = math_floor(data_size / 8)
+ local max_total_samples = num_slices * 20
+
+ if
+ channels ~= file_channels or
+ samplerate ~= file_sampleRate or
+ samples * channels > max_total_samples
+ then
+ --error("Bad frame data", 2)
+ break
+ end
+
+ -- Read the LMS state: 4 x 2 bytes history, 4 x 2 bytes weights per channel
+ for c = 1, channels do
+ lms[c].history = {(">i2i2i2i2"):unpack(assert(read(8), "Invalid QOA data"), nil)}
+ lms[c].weights = {(">i2i2i2i2"):unpack(assert(read(8), "Invalid QOA data"), nil)}
+ end
+
+ -- Decode all slices for all channels in this frame
+ for sample_index = 1, samples, 20 do
+ for c = 1, channels do
+ local sliceH, sliceL = (">I4I4"):unpack(assert(read(8), "Invalid QOA data"), nil)
+
+ local scalefactor = bit32_extract(sliceH, 28, 4)
+
+ for si = sample_index, sample_index + 19 do
+ local predicted = qoa_lms_predict(lms[c])
+ local quantized = bit32_extract(sliceH, 25, 3)
+ local dequantized = qoa_dequant_tab[scalefactor][quantized]
+ --print(predicted, dequantized)
+ local reconstructed = math_min(math_max(predicted + dequantized, -32768), 32767)
+
+ chunk[c][sample_pos + si] = math_floor(reconstructed / 256)
+ sliceH = bit32_lshift(sliceH, 3) + bit32_extract(sliceL, 29, 3)
+ sliceL = bit32_lshift(sliceL, 3)
+
+ qoa_lms_update(lms[c], reconstructed, dequantized)
+ end
+ end
+ end
+ sample_pos = sample_pos + samples
+ end
+
+ if #chunk[1] == 0 then return nil end
+
+ local newlen = #chunk[1] * ratio
+ local lines = {{}}
+ if not mono then for j = 2, file_channels do lines[j] = {} end end
+ local ls = {}
+ for j = 1, file_channels do ls[j] = last[j][2] end
+ for i = 1, newlen do
+ local n = 0
+ for j = 1, file_channels do
+ local x = (i - 1) / ratio + 1
+ local s
+ if x % 1 == 0 then s = chunk[j][x]
+ else s = clamp(interp(chunk[j], x), -128, 127) end
+ s = ls[j] + lp_alpha * (s - ls[j])
+ ls[j] = s
+ if mono then n = n + s
+ else lines[j][i] = s end
+ end
+ if mono then lines[1][i] = n / file_channels end
+ end
+
+ local pos = file_pos / file_sampleRate
+ file_pos = file_pos + sample_pos
+ for i = 1, file_channels do last[i] = {chunk[i][#chunk[i]-1], chunk[i][#chunk[i]]} end
+ return lines, pos
+ end, file_samples / file_sampleRate
+end
+
+--[[
+.########.########.########.########..######..########..######.
+.##.......##.......##.......##.......##....##....##....##....##
+.##.......##.......##.......##.......##..........##....##......
+.######...######...######...######...##..........##.....######.
+.##.......##.......##.......##.......##..........##..........##
+.##.......##.......##.......##.......##....##....##....##....##
+.########.##.......##.......########..######.....##.....######.
+]]
+
+--- aukit.effects
+---@section aukit.effects
+
+--- Amplifies the audio by the multiplier specified.
+---@param audio aukit.Audio The audio to modify
+---@param multiplier number The multiplier to apply
+---@return aukit.Audio _ The audio modified
+function aukit.effects.amplify(audio, multiplier)
+ expectAudio(1, audio)
+ expect(2, multiplier, "number")
+ if multiplier == 1 then return audio end
+ local start = os_epoch "utc"
+ for c = 1, #audio.data do
+ local ch = audio.data[c]
+ for i = 1, #ch do
+ if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end
+ ch[i] = clamp(ch[i] * multiplier, -1, 1)
+ end
+ end
+ return audio
+end
+
+--- Changes the speed and pitch of audio by a multiplier, resampling to keep the
+--- same sample rate.
+---@param audio aukit.Audio The audio to modify
+---@param multiplier number The multiplier to apply
+---@return aukit.Audio _ The audio modified
+function aukit.effects.speed(audio, multiplier)
+ expectAudio(1, audio)
+ expect(2, multiplier, "number")
+ if multiplier == 1 then return audio end
+ local rate = audio.sampleRate
+ audio.sampleRate = audio.sampleRate * multiplier
+ local new = audio:resample(rate)
+ audio.sampleRate, audio.data = rate, new.data
+ return audio
+end
+
+--- Fades a period of music from one amplitude to another.
+---@param audio aukit.Audio The audio to modify
+---@param startTime number The start time of the fade, in seconds
+---@param startAmplitude number The amplitude of the beginning of the fade
+---@param endTime number The end time of the fade, in seconds
+---@param endAmplitude number The amplitude of the end of the fade
+---@return aukit.Audio _ The audio modified
+function aukit.effects.fade(audio, startTime, startAmplitude, endTime, endAmplitude)
+ expectAudio(1, audio)
+ expect(2, startTime, "number")
+ expect(3, startAmplitude, "number")
+ expect(4, endTime, "number")
+ expect(5, endAmplitude, "number")
+ if startAmplitude == 1 and endAmplitude == 1 then return audio end
+ local startt = os_epoch "utc"
+ for c = 1, #audio.data do
+ local ch = audio.data[c]
+ local start = startTime * audio.sampleRate
+ local m = (endAmplitude - startAmplitude) / ((endTime - startTime) * audio.sampleRate)
+ for i = start, endTime * audio.sampleRate do
+ if os_epoch "utc" - startt > 5000 then startt = os_epoch "utc" sleep(0) end
+ ch[i] = clamp(ch[i] * (m * (i - start) + startAmplitude), -1, 1)
+ end
+ end
+ return audio
+end
+
+--- Inverts all channels in the specified audio.
+---@param audio aukit.Audio The audio to modify
+---@return aukit.Audio _ The audio modified
+function aukit.effects.invert(audio)
+ expectAudio(1, audio)
+ for c = 1, #audio.data do
+ local ch = audio.data[c]
+ for i = 1, #ch do ch[i] = -ch[i] end
+ end
+ return audio
+end
+
+--- Normalizes audio to the specified peak amplitude.
+---@param audio aukit.Audio The audio to modify
+---@param peakAmplitude? number The maximum amplitude
+---@param independent? boolean Whether to normalize each channel independently
+---@return aukit.Audio _ The audio modified
+function aukit.effects.normalize(audio, peakAmplitude, independent)
+ expectAudio(1, audio)
+ peakAmplitude = expect(2, peakAmplitude, "number", "nil") or 1
+ expect(3, independent, "boolean", "nil")
+ local mult
+ local start, sampleRate = os_epoch "utc", audio.sampleRate
+ if not independent then
+ local max = 0
+ for c = 1, #audio.data do
+ local ch = audio.data[c]
+ if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end
+ for i = 1, #ch do max = math_max(max, math_abs(ch[i])) end
+ end
+ mult = peakAmplitude / max
+ end
+ for c = 1, #audio.data do
+ local ch = audio.data[c]
+ if independent then
+ local max = 0
+ for i = 1, #ch do max = math_max(max, math_abs(ch[i])) end
+ mult = peakAmplitude / max
+ end
+ if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end
+ for i = 1, #ch do
+ ch[i] = clamp(ch[i] * mult, -1, 1)
+ end
+ end
+ return audio
+end
+
+--- Centers the DC offset of each channel.
+---@param audio aukit.Audio The audio to modify
+---@return aukit.Audio _ The audio modified
+function aukit.effects.center(audio)
+ expectAudio(1, audio)
+ for c = 1, #audio.data do
+ local ch = audio.data[c]
+ for i = 0, #ch - 1, audio.sampleRate do
+ local avg = 0
+ local l = math_min(#ch - i, audio.sampleRate)
+ for j = 1, l do avg = avg + ch[i+j] end
+ avg = avg / l
+ for j = 1, l do ch[i+j] = clamp(ch[i+j] - avg, -1, 1) end
+ end
+ end
+ return audio
+end
+
+--- Trims any extra silence on either end of the specified audio.
+---@param audio aukit.Audio The audio to modify
+---@param threshold? number The maximum value to register as silence
+---@return aukit.Audio _ The audio modified
+function aukit.effects.trim(audio, threshold)
+ expectAudio(1, audio)
+ threshold = expect(2, threshold, "number", "nil") or (1/65536)
+ local s, e
+ for i = 1, #audio.data[1] do
+ for c = 1, #audio.data do if math_abs(audio.data[c][i]) > threshold then s = i break end end
+ if s then break end
+ end
+ for i = #audio.data[1], 1, -1 do
+ for c = 1, #audio.data do if math_abs(audio.data[c][i]) > threshold then e = i break end end
+ if e then break end
+ end
+ local new = str_sub(audio, s / audio.sampleRate, e / audio.sampleRate)
+ audio.data = new.data
+ return audio
+end
+
+--- Adds a delay to the specified audio.
+---@param audio aukit.Audio The audio to modify
+---@param delay number The amount of time to delay for, in seconds
+---@param multiplier? number The multiplier to apply to the delayed audio
+---@return aukit.Audio _ The audio modified
+function aukit.effects.delay(audio, delay, multiplier)
+ expectAudio(1, audio)
+ expect(2, delay, "number")
+ multiplier = expect(3, multiplier, "number", "nil") or 0.5
+ local samples = math_floor(delay * audio.sampleRate)
+ for c = 1, #audio.data do
+ local original = {}
+ local o = audio.data[c]
+ for i = 1, #o do original[i] = o[i] end
+ for i = samples + 1, #o do o[i] = clamp(o[i] + original[i - samples] * multiplier, -1, 1) end
+ end
+ return audio
+end
+
+--- Adds an echo to the specified audio.
+---@param audio aukit.Audio The audio to modify
+---@param delay? number The amount of time to echo after, in seconds
+---@param multiplier? number The decay multiplier to apply to the echoed audio
+---@return aukit.Audio _ The audio modified
+function aukit.effects.echo(audio, delay, multiplier)
+ expectAudio(1, audio)
+ delay = expect(2, delay, "number", "nil") or 1
+ multiplier = expect(3, multiplier, "number", "nil") or 0.5
+ local samples = math_floor(delay * audio.sampleRate)
+ for c = 1, #audio.data do
+ local o = audio.data[c]
+ for i = samples + 1, #o do o[i] = clamp(o[i] + o[i - samples] * multiplier, -1, 1) end
+ end
+ return audio
+end
+
+local combDelayShift = {0, -11.73, 19.31, -7.97}
+local combDecayShift = {0, 0.1313, 0.2743, 0.31}
+
+--- Adds reverb to the specified audio.
+---@param audio aukit.Audio The audio to modify
+---@param delay? number The amount of time to reverb after, in **milliseconds**
+---@param decay? number The decay factor to use
+---@param wetMultiplier? number The wet (reverbed) mix amount
+---@param dryMultiplier? number The dry (original) mix amount
+---@return aukit.Audio _ The audio modified
+function aukit.effects.reverb(audio, delay, decay, wetMultiplier, dryMultiplier)
+ expectAudio(1, audio)
+ delay = expect(2, delay, "number", "nil") or 100
+ decay = expect(3, decay, "number", "nil") or 0.3
+ wetMultiplier = expect(4, wetMultiplier, "number", "nil") or 1
+ dryMultiplier = expect(5, dryMultiplier, "number", "nil") or 0
+ for c = 1, #audio.data do
+ -- four comb filters
+ local sum = {}
+ local o = audio.data[c]
+ for n = 1, 4 do
+ local comb = {}
+ local samples = math_floor((delay + combDelayShift[n]) / 1000 * audio.sampleRate)
+ local multiplier = decay - combDecayShift[n]
+ for i = 1, math_min(samples, #o) do
+ comb[i] = o[i]
+ sum[i] = (sum[i] or 0) + o[i]
+ end
+ for i = samples + 1, #o do
+ local s = o[i] + comb[i - samples] * multiplier
+ comb[i] = s
+ sum[i] = (sum[i] or 0) + s
+ end
+ end
+ -- mix wet/dry
+ for i = 1, #sum do sum[i] = sum[i] * wetMultiplier + o[i] * dryMultiplier end
+ -- two all pass filters
+ local samples = math_floor(0.08927 * audio.sampleRate)
+ sum[samples+1] = sum[samples+1] - 0.131 * sum[1]
+ for i = samples + 2, #sum do sum[i] = sum[i] - 0.131 * sum[i - samples] + 0.131 * sum[i + 20 - samples] end
+ o[samples+1] = clamp(sum[samples+1] - 0.131 * sum[1], -1, 1)
+ for i = samples + 2, #sum do o[i] = clamp(sum[i] - 0.131 * sum[i - samples] + 0.131 * sum[i + 20 - samples], -1, 1) end
+ end
+ return audio
+end
+
+--- Applies a low-pass filter to the specified audio.
+---@param audio aukit.Audio The audio to modify
+---@param frequency number The cutoff frequency for the filter
+---@return aukit.Audio _ The audio modified
+function aukit.effects.lowpass(audio, frequency)
+ expectAudio(1, audio)
+ expect(2, frequency, "number")
+ local a = 1 - math.exp(-(frequency / audio.sampleRate) * 2 * math_pi)
+ for c = 1, #audio.data do
+ local d = audio.data[c]
+ for i = 2, #d do
+ local l = d[i-1]
+ d[i] = l + a * (d[i] - l)
+ end
+ end
+ return audio
+end
+
+--- Applies a high-pass filter to the specified audio.
+---@param audio aukit.Audio The audio to modify
+---@param frequency number The cutoff frequency for the filter
+---@return aukit.Audio _ The audio modified
+function aukit.effects.highpass(audio, frequency)
+ expectAudio(1, audio)
+ expect(2, frequency, "number")
+ local a = 1 / (2 * math_pi * (frequency / audio.sampleRate) + 1)
+ for c = 1, #audio.data do
+ local d = audio.data[c]
+ local lx = d[1]
+ for i = 2, #d do
+ local llx = d[i]
+ d[i] = a * (d[i-1] + llx - lx)
+ lx = llx
+ end
+ end
+ return audio
+end
+
+return aukit
\ No newline at end of file
diff --git a/austream.lua b/austream.lua
new file mode 100644
index 0000000..dde7103
--- /dev/null
+++ b/austream.lua
@@ -0,0 +1,255 @@
+
+local aukit = require "aukit"
+
+local speakers = {peripheral.find("speaker")}
+if #speakers == 0 then error("No speaker attached") end
+if #speakers == 2 and peripheral.getName(speakers[1]) == "right" and peripheral.getName(speakers[2]) == "left" then speakers = {speakers[2], speakers[1]} end
+
+local path = ...
+if not path then error("Usage: austream [arguments for PCM/DFPWM]") end
+local params = {...}
+table.remove(params, 1) -- Remove path
+local v = {}
+if #params > 0 then
+ -- Try to parse as Lua table string first (original behavior)
+ local paramStr = table.concat(params, " ")
+ -- Remove outer braces if they exist to match original austream format
+ paramStr = paramStr:gsub("^%s*{%s*", ""):gsub("%s*}%s*$", "")
+
+ local fn = load("return {" .. paramStr .. "}", "=unserialize", "t", setmetatable({}, {__index = function(_, idx) return idx end}))
+ if fn then
+ local ok, parsed = pcall(fn)
+ if ok and type(parsed) == "table" then
+ for k, val in pairs(parsed) do v[k] = val end
+ end
+ end
+
+ -- If that fails, try manual string matching
+ if not next(v) then
+ local matchStr = table.concat(params, ", ")
+ if matchStr:match("type%s*=%s*['\"]([^'\"]+)['\"]") then
+ v.type = matchStr:match("type%s*=%s*['\"]([^'\"]+)['\"]")
+ end
+ if matchStr:match("volume%s*=%s*([%d%.]+)") then v.volume = tonumber(matchStr:match("volume%s*=%s*([%d%.]+)")) end
+ if matchStr:match("streamData%s*=%s*true") then v.streamData = true end
+ if matchStr:match("silent%s*=%s*true") then v.silent = true end
+ end
+end
+if v.volume == nil then v.volume = 3 end
+if type(v.volume) == "number" then
+ if v.volume < 0 then v.volume = 0 end
+ if v.volume > 3 then v.volume = 3 end
+end
+local mono = v.mono or #speakers == 1
+aukit.defaultInterpolation = v.interpolation or aukit.defaultInterpolation
+local data
+if path:match("^https?://") then
+ local handle, err = http.get(path, nil, true)
+ if not handle then error("Could not connect to " .. path .. ": " .. err) end
+ local code = handle.getResponseCode()
+ if code ~= 200 then handle.close() error("Could not connect to " .. path .. ": HTTP " .. code) end
+ if v.streamData then
+ local closed = false
+ function data()
+ if closed then return nil end
+ local d = handle.read(48000)
+ if not d then handle.close() closed = true return nil end
+ return d
+ end
+ else
+ data = handle.readAll()
+ handle.close()
+ end
+elseif path:match("^wss?://") then
+ local ws, err = http.websocket(path)
+ if not ws then error("Failed to connect to websocket: " .. tostring(err)) end
+
+ -- Buffer completely locally to avoid any network stutter
+ local fullBuffer = ""
+ local isDone = false
+
+ local function cleanup()
+ if not isDone then
+ isDone = true
+ if ws then ws.close() end
+ end
+ end
+
+ -- Initial wait for the first data chunk from the server (which sends a 1s silence chunk on connect)
+ local startWait = os.startTimer(1.0)
+ while true do
+ local event, p1, p2 = os.pullEvent()
+ if event == "websocket_message" and p1 == path then
+ if type(p2) == "string" then fullBuffer = fullBuffer .. p2 end
+ -- Once we get the first message, we can break and start playing
+ break
+ elseif event == "websocket_closed" and p1 == path then
+ cleanup()
+ break
+ elseif event == "timer" and p1 == startWait then
+ break
+ end
+ end
+
+ -- Since WebSocket connection needs to run alongside aukit stream reading,
+ -- we will just use a non-blocking read approach in the data function itself.
+
+ function data(n)
+ n = n or 6000
+
+ if isDone then
+ if #fullBuffer == 0 then return nil end
+ else
+ -- First, read any available messages without blocking
+ -- ws.receive(0) returns immediately. If nil, no message is ready.
+ while true do
+ local msg = ws.receive(0)
+ if msg == nil then break end
+ if type(msg) == "string" then
+ fullBuffer = fullBuffer .. msg
+ end
+ end
+
+ -- If we still don't have enough data, wait for more
+ -- but only if we know more might be coming (not isDone)
+ if #fullBuffer < n then
+ -- We only wait briefly so we don't block the audio thread too long
+ -- If the server isn't sending data, it means silence or end of stream
+ local timeout = os.startTimer(0.1) -- shorter timeout to keep pulling events
+ while #fullBuffer < n do
+ local event, p1, p2 = os.pullEvent()
+ if event == "websocket_message" and p1 == path then
+ if type(p2) == "string" then fullBuffer = fullBuffer .. p2 end
+ elseif event == "websocket_closed" and p1 == path then
+ cleanup()
+ break
+ elseif event == "timer" and p1 == timeout then
+ -- We hit timeout, we must return whatever we have to keep playing
+ break
+ end
+ end
+ end
+ end
+
+ if #fullBuffer == 0 and isDone then return nil end
+ -- Return DFPWM silence (0xAA) if buffer is empty but stream is alive
+ -- This keeps the stream alive without crashing or making noise
+ if #fullBuffer == 0 then return string.rep("\xAA", n) end
+
+ local ret
+ if #fullBuffer >= n then
+ ret = fullBuffer:sub(1, n)
+ fullBuffer = fullBuffer:sub(n + 1)
+ else
+ ret = fullBuffer
+ -- Pad with silence if we don't have enough data to fulfill the request
+ ret = ret .. string.rep("\xAA", n - #ret)
+ fullBuffer = ""
+ end
+ return ret
+ end
+ -- Note: aukit.stream handles the reading internally
+ -- We can use a table with a read function for custom streaming
+ local obj = {
+ read = function(n)
+ local d = data(n)
+ if not d and isDone then cleanup() end
+ return d
+ end,
+ close = cleanup
+ }
+ return obj
+elseif path:match("^rednet://%d+") or path:match("^rednet%+%l+://%d+") then
+ peripheral.find("modem", rednet.open)
+ local proto, id, name = path:match("^rednet%+?(%l*)://(%d+)(/?.*)$")
+ id = tonumber(id)
+ if proto == "" then proto = nil end
+ if path ~= "" and id >= 0 then rednet.send(id, name, proto) end
+ function data()
+ local i, msg
+ repeat i, msg = rednet.receive(proto) until id == -1 or i == id
+ return msg
+ end
+else
+ path = shell.resolve(...)
+ local file, err = fs.open(path, "rb")
+ if not file then error("Could not open " .. path .. ": " .. err) end
+ if v.streamData then
+ local closed = false
+ function data()
+ if closed then return nil end
+ local d = file.read(48000)
+ if not d then file.close() closed = true return nil end
+ return d
+ end
+ else
+ data = file.readAll()
+ file.close()
+ end
+end
+
+
+local iter, length
+if type(v.type) == "string" then v.type = v.type:lower() end
+
+ local isDfpwm = v.type == "dfpwm" or path:lower():match("%.dfpwm$") or path:match("^wss?://")
+ if isDfpwm then
+ if type(data) == "table" and data.read then
+ iter, length = aukit.stream.dfpwm(data, v.sampleRate, v.channels, mono)
+ else
+ iter, length = aukit.stream.dfpwm(data, v.sampleRate, v.channels, mono)
+ end
+ elseif v.type == "mdfpwm" or path:lower():match("%.mdfpwm$") then iter, length = aukit.stream.mdfpwm(data, mono)
+elseif v.type == "wav" or path:lower():match("%.wav$") then iter, length = aukit.stream.wav(data, mono, v.ignoreHeader)
+elseif v.type == "aiff" or path:lower():match("%.aiff?$") then iter, length = aukit.stream.aiff(data, mono, v.ignoreHeader)
+elseif v.type == "au" or path:lower():match("%.au$") then iter, length = aukit.stream.au(data, mono, v.ignoreHeader)
+elseif v.type == "flac" or path:lower():match("%.flac$") then iter, length = aukit.stream.flac(data, mono)
+elseif v.type == "qoa" or path:lower():match("%.qoa$") then iter, length = aukit.stream.qoa(data, mono)
+elseif v.type == "pcm" or path:lower():match("%.pcm$") or path:lower():match("%.raw$") or path:match("^rednet%+?%l*://") then iter, length = aukit.stream.pcm(data, v.bitDepth, v.dataType, v.channels, v.sampleRate, v.bigEndian, mono)
+else error("Unknown file type. Make sure to add the right file extension to the path/URL.") end
+if length == nil then length = 0 end
+
+if not v.silent then
+ print("Streaming...")
+ local w = term.getSize()
+ local y = select(2, term.getCursorPos())
+ local fg, bg = colors.toBlit(term.getTextColor()), colors.toBlit(term.getBackgroundColor())
+ term.write(("00:00 %s %02d:%02d"):format(("\127"):rep(w - 12), math.floor(length / 60), length % 60))
+ aukit.play(iter, function(pos)
+ pos = math.min(pos, 5999)
+ local p = pos / length
+ term.setCursorPos(1, y)
+
+ -- 计算基本时间字符串
+ local timeStr = ("%02d:%02d"):format(math.floor(pos / 60), pos % 60)
+ local endTimeStr = ("%02d:%02d"):format(math.floor(length / 60), length % 60)
+
+ if p > 1 then
+ -- 确保空格数量正确
+ local spaces = (" "):rep(w - 12)
+ local text = timeStr .. " " .. spaces .. "--:--"
+ local fgColor = fg:rep(#text)
+ local bgColor = bg:rep(6) .. fg:rep(w - 12) .. bg:rep(6)
+ -- 确保所有参数长度相等
+ if #text == #fgColor and #text == #bgColor then
+ term.blit(text, fgColor, bgColor)
+ end
+ else
+ local barWidth = w - 12
+ local progress = math.floor(barWidth * p)
+ progress = math.max(0, math.min(progress, barWidth)) -- 确保进度在有效范围内
+
+ local progressBar = (" "):rep(progress) .. ("\127"):rep(barWidth - progress)
+ local text = timeStr .. " " .. progressBar .. " " .. endTimeStr
+ local fgColor = fg:rep(#text)
+ local bgColor = bg:rep(6) .. fg:rep(progress) .. bg:rep(barWidth - progress + 6)
+ -- 确保所有参数长度相等
+ if #text == #fgColor and #text == #bgColor then
+ term.blit(text, fgColor, bgColor)
+ end
+ end
+ end, v.volume, table.unpack(speakers))
+ print()
+else
+ aukit.play(iter, function() end, v.volume, table.unpack(speakers))
+end
diff --git a/install_station_broadcast.lua b/install_station_broadcast.lua
new file mode 100644
index 0000000..9678391
--- /dev/null
+++ b/install_station_broadcast.lua
@@ -0,0 +1,83 @@
+local STATION_BROADCAST_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/station_broadcast.lua?sign=xXsMwezjnEs4c6QrjkJkbvz1rARokmm87zwDcDDS5dk=:0"
+local AUKIT_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/aukit.lua?sign=02_QDktYbSrHrtCDsFlKMkbEOWJGDautZaOtWkvg5q8=:0"
+local AUSTREAM_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/austream.lua?sign=DrLbf9CIX1ZY8DaL8vliZiCMQzGos10nhG26Wf5lbgA=:0"
+
+local function fetch(url)
+ if not http then
+ return nil, "HTTP API unavailable"
+ end
+
+ if http.checkURL then
+ local ok, err = http.checkURL(url)
+ if not ok then
+ return nil, err or "URL blocked"
+ end
+ end
+
+ local res, err = http.get(url)
+ if not res then
+ return nil, err or "request failed"
+ end
+
+ local body = res.readAll()
+ res.close()
+
+ if type(body) ~= "string" or #body == 0 then
+ return nil, "empty response"
+ end
+
+ return body
+end
+
+local function writeFile(path, content)
+ local dir = fs.getDir(path)
+ if dir and dir ~= "" and not fs.exists(dir) then
+ fs.makeDir(dir)
+ end
+
+ local f = fs.open(path, "w")
+ if not f then
+ return nil, "cannot open " .. path
+ end
+
+ f.write(content)
+ f.close()
+ return true
+end
+
+local function downloadTo(url, path)
+ write(("Downloading %s... "):format(path))
+ local body, err = fetch(url)
+ if not body then
+ print("FAILED")
+ print(err)
+ return false
+ end
+
+ local ok, werr = writeFile(path, body)
+ if not ok then
+ print("FAILED")
+ print(werr)
+ return false
+ end
+
+ print("OK")
+ return true
+end
+
+local targets = {
+ { url = STATION_BROADCAST_URL, path = shell.resolve("/startup") },
+ { url = AUKIT_URL, path = shell.resolve("/aukit.lua") },
+ { url = AUSTREAM_URL, path = shell.resolve("/austream.lua") }
+}
+
+for _, t in ipairs(targets) do
+ local ok = downloadTo(t.url, t.path)
+ if not ok then
+ print("Install aborted.")
+ return
+ end
+end
+
+print("Install complete.")
+print("Run 'reboot' to start StationBroadcast on boot.")
diff --git a/station_broadcast-warning.lua b/station_broadcast-warning.lua
new file mode 100644
index 0000000..22729fa
--- /dev/null
+++ b/station_broadcast-warning.lua
@@ -0,0 +1,52 @@
+-- Random Audio Player
+-- Simplified version that only plays random audio files
+
+local COLORS = {
+ INFO = colors.white,
+ WARN = colors.yellow,
+ WARNING = colors.yellow,
+ ERROR = colors.red,
+ DEBUG = colors.cyan,
+ SUCCESS = colors.green
+}
+
+-- Debug output function
+local function debugPrint(level, message)
+ local timestamp = os.date("%H:%M:%S")
+ local logMessage = string.format("[%s][%s] %s", timestamp, level, message)
+ local color = COLORS[level] or colors.white
+ local oldColor = term.getTextColor()
+ term.setTextColor(color)
+ print(logMessage)
+ term.setTextColor(oldColor)
+end
+
+-- Add random audio playback function
+local function playRandomAudio()
+ local audioFiles = {"aqtx", "jzxy"}
+ local randomFile = audioFiles[math.random(#audioFiles)]
+ local path = "/Audio/"..randomFile..".dfpwm"
+
+ if fs.exists(path) then
+ debugPrint("INFO", "Attempting to play: "..path)
+ shell.run("austream "..path.." volume=3.0")
+ debugPrint("INFO", "Finished playing: "..randomFile)
+ else
+ debugPrint("ERROR", "Audio file not found: "..path)
+ end
+end
+
+-- Main loop
+local elapsedSeconds = 0
+while true do
+ if elapsedSeconds >= 240 then
+ playRandomAudio()
+ elapsedSeconds = 0
+ end
+
+ os.sleep(1)
+ elapsedSeconds = elapsedSeconds + 1
+end
+
+
+
diff --git a/station_broadcast.lua b/station_broadcast.lua
new file mode 100644
index 0000000..b53fa6b
--- /dev/null
+++ b/station_broadcast.lua
@@ -0,0 +1,1345 @@
+
+-- ============================================================================
+-- 1. Configuration & Constants
+-- ============================================================================
+local CONFIG = {
+ CLOUD_URL = "ws://stabadmin.fse-media.group",
+ API_URL = "http://stabadmin.fse-media.group",
+ SYNC_INTERVAL = 30,
+ AUDIO_BASE = "Audio/",
+ AUDIO_STATION = "Audio/Station/",
+ DISK_AUDIO_BASE = "disk/Audio/",
+ DISK_AUDIO_STATION = "disk/Audio/Station/",
+ AUDIO_VOLUME = 3,
+ DEBUG = true
+}
+
+local aukit = require "aukit"
+
+local COLORS = {
+ INFO = colors.white,
+ WARN = colors.yellow,
+ WARNING = colors.yellow,
+ ERROR = colors.red,
+ DEBUG = colors.cyan,
+ SUCCESS = colors.green
+}
+
+-- ============================================================================
+-- 2. State Management
+-- ============================================================================
+local State = {
+ ws = nil,
+ stationName = "Unknown",
+ stationCode = "UNK",
+ stationPlatform = "1",
+ registered = false,
+ stationList = {},
+ currentTrain = nil,
+ logs = {},
+ logScrollOffset = 0,
+ departingPlayed = false,
+ arrivalTonePlayed = false,
+ shortStopMode = false,
+ audioRx = nil,
+ audioQueue = {},
+ currentPlaying = nil,
+ currentPriority = -1,
+ interruptSeq = 0,
+ lastArrivalPlayAt = 0
+}
+
+-- ============================================================================
+-- 3. Peripherals Setup
+-- ============================================================================
+local Peripherals = {}
+
+function Peripherals.init()
+ -- Station Peripheral
+ Peripherals.station = peripheral.wrap("back", "train_station")
+ or error("Train station peripheral required on 'back'")
+ -- Monitors
+ local monitors = { peripheral.find("monitor") }
+ if #monitors == 0 then error("At least one monitor is required") end
+ -- Assign monitors
+ Peripherals.primaryMonitor = monitors[1]
+ Peripherals.primaryMonitor.setTextScale(0.5)
+ -- Speakers
+ Peripherals.speakers = { peripheral.find("speaker") }
+end
+
+-- ============================================================================
+-- 4. Utility Functions
+-- ============================================================================
+local Utils = {}
+
+function Utils.log(level, message)
+ local timestamp = os.date("%H:%M:%S")
+ local entryText = string.format("[%s][%s] %s", timestamp, level, message)
+ local entry = { level = level, text = entryText }
+ table.insert(State.logs, 1, entry)
+ if #State.logs > 100 then table.remove(State.logs) end
+
+ -- If user is scrolling history, increment offset to stay on same items
+ if State.logScrollOffset > 0 then
+ State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 1))
+ end
+end
+
+function Utils.getStationCode(name)
+ if not name then return nil end
+ name = tostring(name)
+
+ local code = nil
+ local codeInParens = name:match("[%[%(]([%w%d%-%/%s]+)[%]%)]")
+ if codeInParens then
+ local firstPart = codeInParens:match("[^/]+")
+ if firstPart then
+ code = firstPart:match("^%s*(.-)%s*$")
+ end
+ end
+
+ if not code then
+ code = name:match("[%w%d]+%-[%w%d]+") or name:match("[%a]+[%d]+")
+ end
+
+ if not code then
+ local cleanName = name:match("^%s*(.-)%s*$"):lower()
+ if State.stationList and State.stationList[cleanName] then
+ code = cleanName
+ end
+ end
+
+ if code then
+ return tostring(code):lower() -- Always return lowercase for consistent comparison
+ end
+ return nil
+end
+
+function Utils.getPlatformNumber(name, default)
+ if not name then return default end
+ local p = name:match("[Pp]latform%s*([%d]+[a-zA-Z]?)") or name:match("[Tt]rack%s*([%d]+[a-zA-Z]?)")
+ if p then return p end
+ return name:match("([%d]+[a-zA-Z]?)$") or default
+end
+
+function Utils.getBroadcastStationCode(name)
+ if not name then return "UNK" end
+ name = name:match("^%s*(.-)%s*$")
+ name = name:gsub("%s+", "_")
+ if name == "" then return "UNK" end
+ return name
+end
+
+function Utils.isSameTrain(id1, id2)
+ if not id1 or not id2 then return false end
+ if id1 == id2 then return true end
+
+ -- Consist Car Match: "CK 1 01" vs "CK 1 02"
+ local function getConsist(id)
+ local clean = id:gsub("^%[.-%]%s*", "")
+ local p = {}
+ for s in clean:gmatch("%S+") do table.insert(p, s) end
+ if #p >= 2 then
+ if #p[1] <= 4 and tonumber(p[2]) then
+ return p[1] .. " " .. p[2]
+ end
+ end
+ return p[1] or clean
+ end
+
+ local c1 = getConsist(id1)
+ local c2 = getConsist(id2)
+ if c1 == c2 then return true end
+
+ -- Fuzzy match: remove type prefix and compare content
+ local _, name1, num1 = Utils.parseTrainId(id1)
+ local _, name2, num2 = Utils.parseTrainId(id2)
+
+ -- If both have numbers, compare name and number
+ if num1 ~= "" and num2 ~= "" then
+ return name1 == name2 and num1 == num2
+ end
+
+ -- Fallback to comparing just the main content/name
+ return name1 == name2
+end
+
+function Utils.parseTrainId(trainId)
+ if not trainId then return "unknown", "" end
+
+ -- Generic match: [Type]Content
+ local typeStr, content = trainId:match("^%[(.-)%](.+)$")
+
+ if typeStr then
+ -- Check for Express types
+ if typeStr == "Ltd.Exp" or typeStr == "Express" then
+
+ local name, num = content:match("^(.+) No%.(%d+)$")
+ if name then
+ return "express", name, num
+ else
+ return "express", content, ""
+ end
+ elseif typeStr == "Local" then
+ return "regular", "Local", content
+ else
+ return "regular", typeStr, content
+ end
+ end
+
+ return "unknown", "Unknown", trainId
+end
+
+function Utils.getDestination(schedule)
+ if not schedule then return "Unknown" end
+ -- Handle both wrapped and unwrapped schedule structure
+ local entries = schedule.entries or (type(schedule) == "table" and schedule)
+ if not entries or type(entries) ~= "table" then return "Unknown" end
+
+
+ if #entries == 0 then
+ local count = 0
+ for _ in pairs(entries) do count = count + 1 end
+ if count > 0 then
+
+ local list = {}
+ for k, v in pairs(entries) do
+ local n = tonumber(k)
+ if n then list[n] = v end
+ end
+ entries = list
+ end
+ end
+
+ local dest = "Unknown"
+ local maxIdx = 0
+ for k, _ in pairs(entries) do
+ if type(k) == "number" and k > maxIdx then maxIdx = k end
+ end
+
+
+ for i = 1, maxIdx do
+ local entry = entries[i]
+ if entry then
+ local inst = entry.instruction
+ if inst then
+ if inst.id == "create:destination" then
+ dest = (inst.data and inst.data.text) or inst.text or dest
+ end
+
+ if inst.id == "create:station" then
+ local sName = (inst.data and inst.data.text) or inst.text
+ if sName then
+ dest = sName
+ end
+ end
+ end
+ end
+ end
+
+ return dest
+end
+
+function Utils.getBaseName(name)
+ if not name then return "" end
+ name = tostring(name):lower()
+ name = name:gsub("%s*[Pp]latform%s*[%d%a]+", ""):gsub("%s*[Tt]rack%s*[%d%a]+", "")
+ name = name:gsub("%s*[%[%(].-[%]%)]%s*", " ")
+ name = name:gsub("%s+%d+$", "")
+ return name:match("^%s*(.-)%s*$")
+end
+
+function Utils.isFuzzyMatch(str, pattern)
+ if not str or not pattern then return false end
+ str = tostring(str):lower():match("^%s*(.-)%s*$")
+ pattern = tostring(pattern):lower():match("^%s*(.-)%s*$")
+
+ if str == pattern then return true end
+
+ -- 1. Handle Create Mod Wildcards (*)
+ if pattern:find("*") then
+ local p = pattern:gsub("([%^%$%(%)%%%.%[%]%+%-%?])", "%%%1")
+ p = p:gsub("%%%*", ".*")
+ local ok, res = pcall(function() return str:match("^" .. p .. "$") end)
+ if ok and res then return true end
+ end
+
+ -- 2. Base name comparison
+ local b1 = Utils.getBaseName(str)
+ local b2 = Utils.getBaseName(pattern)
+ if b1 ~= "" and b2 ~= "" then
+ if b1 == b2 then return true end
+ -- Substring match for base names
+ if #b1 >= 4 and #b2 >= 4 then
+ if b1:find(b2, 1, true) or b2:find(b1, 1, true) then
+ return true
+ end
+ end
+ end
+
+ -- 3. Alphanumeric only comparison
+ local a1 = str:gsub("[^%w%d]", "")
+ local a2 = pattern:gsub("[^%w%d]", "")
+ if a1 ~= "" and a2 ~= "" and (a1 == a2 or a1:find(a2, 1, true) or a2:find(a1, 1, true)) then
+ return true
+ end
+
+ return false
+end
+
+function Utils.getStopTime(schedule, stationName)
+ if not schedule or not schedule.entries or not stationName then return nil end
+
+ local target = tostring(stationName):lower():match("^%s*(.-)%s*$")
+ if target == "" then return nil end
+
+ local targetCode = Utils.getStationCode(target)
+ local targetBase = Utils.getBaseName(target)
+ local targetAlpha = target:gsub("[^%w%d]", "")
+
+ Utils.log("DEBUG", string.format("STATION TARGET: '%s' (Code: %s, Base: %s, Alpha: %s)", target, tostring(targetCode), targetBase, targetAlpha))
+
+ local function extractTime(data)
+ if not data then return nil end
+ local val = tonumber(data.value) or tonumber(data.seconds) or tonumber(data.s)
+ or tonumber(data.time) or tonumber(data.duration) or tonumber(data.delay)
+ local unit = tonumber(data.time_unit) or tonumber(data.unit) or 1
+ if val then
+ if unit == 0 then return val / 20
+ elseif unit == 1 then return val
+ elseif unit == 2 then return val * 60
+ end
+ return val
+ end
+ local ticks = tonumber(data.ticks) or tonumber(data.tick)
+ if ticks then return ticks / 20 end
+ return nil
+ end
+
+ local function findTimeInConditions(conditions)
+ if not conditions then return nil end
+ for i, item in ipairs(conditions) do
+ if type(item) == "table" then
+ local cid = tostring(item.id or ""):lower()
+ if cid:find("delay") or cid:find("wait") or cid:find("idle") or cid:find("pause") then
+ local s = extractTime(item.data)
+ if s and s > 0 then
+ Utils.log("DEBUG", string.format("Found Time in Condition [%d]: %.1fs (ID: %s)", i, s, cid))
+ return s
+ end
+ end
+ for k, sub in pairs(item) do
+ if type(sub) == "table" then
+ local s = findTimeInConditions({sub})
+ if s then return s end
+ end
+ end
+ end
+ end
+ return nil
+ end
+
+ local maxIdx = 0
+ if schedule.entries then
+ for k, _ in pairs(schedule.entries) do
+ if type(k) == "number" and k > maxIdx then maxIdx = k end
+ end
+ end
+
+ Utils.log("DEBUG", string.format("--- Schedule Scan Start (MaxIdx: %d) ---", maxIdx))
+
+ local matchFoundIndex = nil
+
+ for i = 1, maxIdx do
+ local entry = schedule.entries[i]
+ if entry then
+ local function scanRecursive(obj, depth)
+ if depth > 5 or not obj then return false end
+ for k, v in pairs(obj) do
+ if type(v) == "string" then
+ local entryText = v:lower()
+ local entryCode = Utils.getStationCode(entryText)
+ local entryAlpha = entryText:gsub("[^%w%d]", "")
+ local entryBase = Utils.getBaseName(entryText)
+
+ Utils.log("DEBUG", string.format(" [%d] SCAN '%s': '%s' (Code: %s)", i, k, entryText, tostring(entryCode)))
+
+ local isMatch = (target == entryText)
+ or (targetCode and entryCode and targetCode == entryCode)
+ or (targetAlpha ~= "" and entryAlpha ~= "" and (targetAlpha == entryAlpha or targetAlpha:find(entryAlpha, 1, true) or entryAlpha:find(targetAlpha, 1, true)))
+ or (targetBase ~= "" and entryBase ~= "" and (targetBase == entryBase or targetBase:find(entryBase, 1, true) or entryBase:find(targetBase, 1, true)))
+ or Utils.isFuzzyMatch(target, entryText)
+
+ if isMatch then
+ Utils.log("DEBUG", string.format(" >>> MATCH FOUND AT ENTRY %d (via field '%s')", i, k))
+ return true
+ end
+ elseif type(v) == "table" then
+ if scanRecursive(v, depth + 1) then return true end
+ end
+ end
+ return false
+ end
+
+ if scanRecursive(entry, 0) then
+ matchFoundIndex = i
+ local s = findTimeInConditions(entry.conditions)
+ if s then
+ Utils.log("DEBUG", string.format(" >>> TIME RESOLVED FROM CONDITIONS: %.1fs", s))
+ return s
+ end
+
+ if entry.instruction and entry.instruction.data then
+ local s = extractTime(entry.instruction.data)
+ if s then
+ Utils.log("DEBUG", string.format(" >>> TIME RESOLVED FROM INSTRUCTION: %.1fs", s))
+ return s
+ end
+ end
+
+ break
+ end
+ end
+ end
+
+ if matchFoundIndex then
+ Utils.log("DEBUG", string.format(" >>> Station matched at [%d], scanning ahead for time...", matchFoundIndex))
+ for i = matchFoundIndex + 1, maxIdx do
+ local entry = schedule.entries[i]
+ if entry then
+ local isOtherStation = false
+ local function checkIsStation(obj, depth)
+ if depth > 2 or not obj then return false end
+ for k, v in pairs(obj) do
+ if type(v) == "string" then
+ local lk = tostring(k):lower()
+ if lk:find("text") or lk:find("station") or lk:find("destination") then return true end
+ elseif type(v) == "table" then
+ if checkIsStation(v, depth + 1) then return true end
+ end
+ end
+ return false
+ end
+ if checkIsStation(entry, 0) then break end
+
+ local s = findTimeInConditions(entry.conditions)
+ if s then
+ Utils.log("DEBUG", string.format(" >>> TIME FOUND AT LATER ENTRY [%d]: %.1fs", i, s))
+ return s
+ end
+ if entry.instruction and entry.instruction.data then
+ local s = extractTime(entry.instruction.data)
+ if s then
+ Utils.log("DEBUG", string.format(" >>> TIME FOUND AT LATER INSTRUCTION [%d]: %.1fs", i, s))
+ return s
+ end
+ end
+ end
+ end
+ end
+
+ Utils.log("DEBUG", "--- Scan End: No Time Resolved ---")
+ return nil
+end
+
+-- ============================================================================
+-- 5. Audio System
+-- ============================================================================
+local Audio = {}
+
+function Audio.createStream()
+ local chunks = {}
+ local finished = false
+ local stream = {}
+
+ stream.push = function(chunk)
+ if type(chunk) ~= "string" then return end
+ table.insert(chunks, chunk)
+ os.queueEvent("audio_chunk")
+ end
+
+ stream.finish = function()
+ finished = true
+ os.queueEvent("audio_chunk")
+ end
+
+ stream.read = function()
+ while #chunks == 0 do
+ if finished then return nil end
+ os.pullEvent("audio_chunk")
+ end
+ return table.remove(chunks, 1)
+ end
+
+ return stream
+end
+
+function Audio.loadStationList()
+ State.stationList = {}
+ if not fs.exists("list.txt") then
+ Utils.log("WARN", "list.txt not found")
+ return
+ end
+
+ local file = fs.open("list.txt", "r")
+ if file then
+ local count = 0
+ local line = file.readLine()
+ while line do
+ line = line:gsub("%s+$", "") -- Trim trailing \r\n and spaces
+ if line ~= "" then
+ local codes, files = line:match("^([^=]+)==(.+)$")
+ if codes and files then
+ local list = {}
+ for f in files:gmatch("[^,]+") do
+ local cleanName = f:match("^%s*(.-)%s*$"):gsub("%.wav$", ""):gsub("%.dfpwm$", "")
+ if cleanName ~= "" then
+ table.insert(list, cleanName)
+ end
+ end
+
+ -- Map all codes to this list
+ local allCodes = {}
+ for code in codes:gmatch("[^/]+") do
+ local cleanCode = code:match("^%s*(.-)%s*$"):lower()
+ if cleanCode ~= "" then
+ table.insert(allCodes, cleanCode)
+ State.stationList[cleanCode] = list
+ end
+ end
+
+ if #allCodes > 1 then
+ local fullRawCode = codes:match("^%s*(.-)%s*$"):lower()
+ State.stationList[fullRawCode] = list
+ local dashCode = table.concat(allCodes, "-"):lower()
+ State.stationList[dashCode] = list
+ end
+ count = count + 1
+ end
+ end
+ line = file.readLine()
+ end
+ file.close()
+ Utils.log("INFO", string.format("Station list loaded: %d entries", count))
+ end
+end
+
+function Audio.enqueueDfpwm(dfpwmData)
+ if type(dfpwmData) ~= "string" or #dfpwmData == 0 then return end
+ Audio.enqueueItem({ dfpwmData = dfpwmData, priority = 1 })
+end
+
+function Audio.enqueueItem(item)
+ if type(item) ~= "table" then return end
+ if type(item.dfpwmData) ~= "string" and type(item.dfpwmData) ~= "table" then return end
+ item.priority = tonumber(item.priority) or 0
+ local idx = 1
+ while idx <= #State.audioQueue do
+ local p = tonumber(State.audioQueue[idx].priority) or 0
+ if item.priority > p then break end
+ idx = idx + 1
+ end
+ table.insert(State.audioQueue, idx, item)
+ os.queueEvent("audio_queue")
+end
+
+function Audio.stop(mode, flushBelow)
+ State.interruptSeq = State.interruptSeq + 1
+ if State.audioRx and State.audioRx.stream then
+ pcall(State.audioRx.stream.finish)
+ end
+ State.audioRx = nil
+ if Peripherals.speakers then
+ for _, sp in ipairs(Peripherals.speakers) do
+ pcall(function()
+ if type(sp.speakStop) == "function" then sp.speakStop()
+ else sp.stop() end
+ end)
+ end
+ end
+
+ if mode == "all" then
+ State.audioQueue = {}
+ elseif flushBelow ~= nil then
+ local minP = tonumber(flushBelow) or 0
+ local kept = {}
+ for _, it in ipairs(State.audioQueue) do
+ local p = tonumber(it.priority) or 0
+ if p >= minP then table.insert(kept, it) end
+ end
+ State.audioQueue = kept
+ end
+
+ os.queueEvent("audio_interrupt", State.interruptSeq)
+end
+
+local function shouldStop(playSeq)
+ return State.interruptSeq ~= playSeq
+end
+
+local function playChunkOnSpeakers(samples, volume, playSeq)
+ if not Peripherals.speakers or #Peripherals.speakers == 0 then return false end
+ local controller = Peripherals.speakers[1]
+ if controller and type(controller.playAudioAll) == "function" then
+ while true do
+ if shouldStop(playSeq) then return false end
+ local ok = false
+ pcall(function()
+ ok = controller.playAudioAll(samples, volume)
+ end)
+ if ok then break end
+ local ev = { os.pullEvent() }
+ if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
+ return false
+ end
+ end
+ else
+ local pending = {}
+ for _, sp in ipairs(Peripherals.speakers) do
+ local name = peripheral.getName(sp)
+ if name then pending[name] = sp end
+ end
+
+ while next(pending) do
+ if shouldStop(playSeq) then return false end
+ for name, sp in pairs(pending) do
+ local ok = false
+ pcall(function()
+ ok = sp.playAudio(samples, volume)
+ end)
+ if ok then pending[name] = nil end
+ end
+ if next(pending) then
+ local ev = { os.pullEvent() }
+ if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
+ return false
+ end
+ end
+ end
+ end
+
+ local waiting = {}
+ for _, sp in ipairs(Peripherals.speakers) do
+ local name = peripheral.getName(sp)
+ if name then waiting[name] = true end
+ end
+
+ while next(waiting) do
+ if shouldStop(playSeq) then return false end
+ local ev = { os.pullEvent() }
+ if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
+ return false
+ end
+ if ev[1] == "speaker_audio_empty" and ev[2] and waiting[ev[2]] then
+ waiting[ev[2]] = nil
+ end
+ end
+
+ return true
+end
+
+function Audio.playDfpwmItem(item)
+ if not Peripherals.speakers or #Peripherals.speakers == 0 then
+ Utils.log("WARN", "No speakers found; cannot play audio")
+ return
+ end
+
+ local playSeq = State.interruptSeq
+ local source = item.dfpwmData
+
+ -- Support both static string data and streaming functions
+ if type(source) == "table" and source.read then
+ source = source.read
+ end
+
+ local ok, iter = pcall(aukit.stream.dfpwm, source, 48000, 1, false)
+ if not ok then
+ Utils.log("ERROR", "Failed to create audio stream: " .. tostring(iter))
+ return
+ end
+
+ for chunk in iter do
+ if shouldStop(playSeq) then break end
+ if not playChunkOnSpeakers(chunk[1], CONFIG.AUDIO_VOLUME, playSeq) then break end
+ end
+end
+
+function Audio.playerLoop()
+ while true do
+ if #State.audioQueue == 0 then
+ os.pullEvent("audio_queue")
+ else
+ local item = table.remove(State.audioQueue, 1)
+ State.currentPlaying = item
+ State.currentPriority = tonumber(item.priority) or 0
+
+ local ok, err = pcall(Audio.playDfpwmItem, item)
+ if not ok then
+ Utils.log("ERROR", "Audio Playback Crash: " .. tostring(err))
+ end
+
+ State.currentPlaying = nil
+ State.currentPriority = -1
+ end
+ end
+end
+
+function Utils.getChineseNumberAudio(val)
+ local num = tonumber(val)
+ if num then
+ local seq = {}
+ if num < 10 then
+ table.insert(seq, "ch-" .. num)
+ elseif num == 10 then
+ table.insert(seq, "ch-10")
+ elseif num > 10 and num < 20 then
+ table.insert(seq, "ch-10")
+ table.insert(seq, "ch-" .. (num % 10))
+ elseif num >= 20 and num < 100 then
+ local ten = math.floor(num / 10)
+ local unit = num % 10
+ table.insert(seq, "ch-" .. ten)
+ table.insert(seq, "ch-10")
+ if unit > 0 then
+ table.insert(seq, "ch-" .. unit)
+ end
+ else
+ table.insert(seq, "ch-" .. num)
+ end
+ return seq
+ else
+ -- Handle Alphanumeric (e.g., "1A", "2B")
+ local digit, letter = tostring(val):match("^(%d+)([a-zA-Z])$")
+ if digit and letter then
+ local seq = Utils.getChineseNumberAudio(digit)
+ table.insert(seq, letter:upper()) -- e.g. "A", "B"
+ return seq
+ end
+ return {tostring(val)}
+ end
+end
+
+function Utils.getStationAudio(code, langPrefix)
+ if not code then return nil end
+ local files = State.stationList[code]
+ if files then
+ local p = langPrefix:lower()
+ for _, f in ipairs(files) do
+ if f:lower():find("^" .. p) then
+ return f
+ end
+ end
+ end
+ -- Fallback to hardcoded pattern
+ return langPrefix .. code
+end
+
+function Audio.announceArrival(trainId, trainType, platform, dest, useAltJz3)
+ local seq = {}
+ Utils.log("DEBUG", string.format("Announcing Arrival: %s, Type: %s, Plat: %s, Dest: %s",
+ tostring(trainId), tostring(trainType), tostring(platform), tostring(dest)))
+
+ table.insert(seq, "ksts") -- Chime
+ table.insert(seq, "__DELAY_1__")
+
+ local destCode = Utils.getStationCode(dest)
+ if (not destCode or destCode == "unknown") and dest ~= "Unknown" then
+ -- Fallback to broadcast code if regular code failed
+ destCode = Utils.getBroadcastStationCode(dest)
+ Utils.log("DEBUG", "Station code fallback for " .. tostring(dest) .. ": " .. tostring(destCode))
+ end
+
+ Utils.log("DEBUG", "Final destination code for " .. tostring(dest) .. ": " .. tostring(destCode))
+
+ local chJz3 = useAltJz3 and "ch-jz3-1" or "ch-jz3"
+ local enJz3 = useAltJz3 and "en-jz3-1" or "en-jz3"
+
+ -- Chinese
+ table.insert(seq, "ch-jz1")
+ if destCode then
+ table.insert(seq, Utils.getStationAudio(destCode, "ch-"))
+ end
+ table.insert(seq, "ch-jz2")
+ table.insert(seq, trainType == "express" and "ch-tj" or "ch-pt")
+ table.insert(seq, chJz3)
+
+ -- Platform Number (Chinese)
+ local pAudio = Utils.getChineseNumberAudio(platform)
+ for _, a in ipairs(pAudio) do table.insert(seq, a) end
+
+ table.insert(seq, "ch-zt")
+ table.insert(seq, "ch-jz4")
+
+ -- English
+ table.insert(seq, "en-jz1")
+ table.insert(seq, trainType == "express" and "en-tj" or "en-pt")
+ table.insert(seq, "en-jz2")
+ if destCode then
+ table.insert(seq, Utils.getStationAudio(destCode, "en-"))
+ end
+ table.insert(seq, enJz3)
+ table.insert(seq, "en-zt")
+
+ -- English Platform Number
+ local pDigit, pLetter = tostring(platform):match("^(%d+)([a-zA-Z])$")
+ if pDigit and pLetter then
+ table.insert(seq, "en-" .. pDigit)
+ table.insert(seq, pLetter:upper())
+ else
+ table.insert(seq, "en-" .. platform)
+ end
+
+ table.insert(seq, "en-jz4")
+
+ Audio.play(seq)
+end
+
+function Audio.announceDeparture(trainId, trainType, platform, dest, time)
+ local seq = {}
+ table.insert(seq, "fcts")
+ table.insert(seq, "__DELAY_1__")
+
+ local destCode = Utils.getStationCode(dest)
+
+ -- Chinese
+ -- Platform Number
+ local pAudio = Utils.getChineseNumberAudio(platform)
+ for _, a in ipairs(pAudio) do table.insert(seq, a) end
+
+ table.insert(seq, "ch-zt")
+ table.insert(seq, "ch-fc1")
+ table.insert(seq, trainType == "express" and "ch-tj" or "ch-pt")
+ table.insert(seq, "ch-fc2")
+ if destCode then
+ table.insert(seq, Utils.getStationAudio(destCode, "ch-"))
+ end
+ table.insert(seq, "ch-fc3")
+
+ -- English
+ table.insert(seq, "en-fc1")
+ table.insert(seq, trainType == "express" and "en-tj" or "en-pt")
+ table.insert(seq, "en-fc2")
+ table.insert(seq, "en-zt")
+
+ -- English Platform Number
+ local pDigit, pLetter = tostring(platform):match("^(%d+)([a-zA-Z])$")
+ if pDigit and pLetter then
+ table.insert(seq, "en-" .. pDigit)
+ table.insert(seq, pLetter:upper())
+ else
+ table.insert(seq, "en-" .. platform)
+ end
+
+ table.insert(seq, "en-fc3")
+ if destCode then
+ table.insert(seq, Utils.getStationAudio(destCode, "en-"))
+ end
+ table.insert(seq, "en-fc4")
+
+ Audio.play(seq)
+end
+
+-- ============================================================================
+-- 6. Network System
+-- ============================================================================
+local Network = {}
+
+function Audio.play(files)
+ if not State.ws then
+ Utils.log("WARN", "Cannot play audio: Not connected to server")
+ return
+ end
+
+ Utils.log("INFO", "Requesting server to play sequence...")
+ Network.send({
+ type = "play_sequence",
+ stationName = State.stationName,
+ seq = files,
+ category = "train",
+ priority = 1,
+ requestId = tostring(os.epoch("utc")) .. "-" .. tostring(math.random(100000, 999999))
+ })
+end
+
+function Network.connect()
+ if State.ws then return end
+ Utils.log("INFO", "Connecting to cloud...")
+ State.ws = http.websocket(CONFIG.CLOUD_URL)
+ if State.ws then
+ Utils.log("SUCCESS", "Connected")
+ Network.register()
+ else
+ Utils.log("ERROR", "Connection failed")
+ end
+end
+
+function Network.send(data)
+ if State.ws then
+ local ok, err = pcall(function()
+ State.ws.send(textutils.serializeJSON(data))
+ end)
+ if not ok then
+ Utils.log("ERROR", "Failed to send WS message: " .. tostring(err))
+ end
+ end
+end
+
+function Network.register()
+ State.stationName = (Peripherals.station.getStationName() or "Unknown"):match("^%s*(.-)%s*$") -- Trim
+ -- Try to get a clean code from name, fallback to broadcast format
+ State.stationCode = Utils.getStationCode(State.stationName) or Utils.getBroadcastStationCode(State.stationName)
+ State.stationPlatform = Utils.getPlatformNumber(State.stationName, "1")
+
+ Network.send({
+ type = "station_register",
+ stationName = State.stationName,
+ stationCode = State.stationCode,
+ stationPlatform = State.stationPlatform
+ })
+end
+
+function Network.syncFiles()
+ -- Sync only config file (list.txt) periodically
+ while true do
+ local resp = http.get(CONFIG.API_URL .. "/uploads/list.txt")
+ if resp then
+ local content = resp.readAll()
+ resp.close()
+ if content and #content > 0 then
+ local currentList = ""
+ if fs.exists("list.txt") then
+ local f = fs.open("list.txt", "r")
+ currentList = f.readAll()
+ f.close()
+ end
+
+ if currentList ~= content then
+ local f = fs.open("list.txt", "w")
+ f.write(content)
+ f.close()
+ Audio.loadStationList()
+ end
+ end
+ end
+ os.sleep(CONFIG.SYNC_INTERVAL)
+ end
+end
+
+function Network.messageLoop()
+ while true do
+ local event, url, msg = os.pullEvent()
+ if event == "websocket_message" and url == CONFIG.CLOUD_URL then
+ local ok, data = pcall(textutils.unserializeJSON, msg)
+ if ok and type(data) == "table" then
+ if data.type == "audio_start" then
+ if State.audioRx and State.audioRx.stream then
+ pcall(State.audioRx.stream.finish)
+ end
+ local priority = tonumber(data.priority) or 0
+ local interruptMode = tostring(data.interruptMode or "")
+ if interruptMode == "" then
+ if priority >= 2 then interruptMode = "all"
+ elseif priority >= 1 then interruptMode = "ifLower"
+ else interruptMode = "none" end
+ end
+
+ if interruptMode == "all" then
+ Audio.stop("all")
+ elseif interruptMode == "ifLower" and priority > (State.currentPriority or -1) then
+ Audio.stop("current", priority)
+ end
+
+ local stream = Audio.createStream()
+ State.audioRx = {
+ audioId = data.audioId,
+ expectedBytes = tonumber(data.byteLength) or 0,
+ receivedBytes = 0,
+ stream = stream,
+ priority = priority,
+ source = data.source or data.category
+ }
+
+ -- Start playing immediately by enqueuing the stream
+ Audio.enqueueItem({
+ dfpwmData = stream,
+ priority = priority,
+ source = State.audioRx.source,
+ audioId = data.audioId
+ })
+ elseif data.type == "audio_end" then
+ if State.audioRx and (not data.audioId or data.audioId == State.audioRx.audioId) then
+ State.audioRx.stream.finish()
+ State.audioRx = nil
+ end
+ elseif data.type == "audio_stop" then
+ local scope = tostring(data.scope or "")
+ if scope == "music" then
+ local kept = {}
+ for _, it in ipairs(State.audioQueue) do
+ if tostring(it.source or "") ~= "music" then
+ table.insert(kept, it)
+ end
+ end
+ State.audioQueue = kept
+
+ if State.audioRx and tostring(State.audioRx.source or "") == "music" then
+ State.audioRx = nil
+ end
+
+ if State.currentPlaying and tostring(State.currentPlaying.source or "") == "music" then
+ Audio.stop("current")
+ end
+ else
+ local mode = tostring(data.mode or "current")
+ local flushBelow = data.flushBelow
+ Audio.stop(mode, flushBelow)
+ end
+ end
+ else
+ if State.audioRx then
+ -- Memory protection: limit to 10MB per audio item
+ if State.audioRx.receivedBytes + #msg > 10 * 1024 * 1024 then
+ Utils.log("ERROR", "Audio item too large, discarding")
+ State.audioRx.stream.finish()
+ State.audioRx = nil
+ else
+ State.audioRx.stream.push(msg)
+ State.audioRx.receivedBytes = State.audioRx.receivedBytes + #msg
+ if State.audioRx.expectedBytes > 0 and State.audioRx.receivedBytes >= State.audioRx.expectedBytes then
+ State.audioRx.stream.finish()
+ State.audioRx = nil
+ end
+ end
+ end
+ end
+ elseif event == "websocket_closed" then
+ Utils.log("WARN", "WS Closed")
+ State.ws = nil
+ State.audioRx = nil -- Clear partial download
+ os.sleep(5)
+ end
+ end
+end
+
+function Network.heartbeat()
+ while true do
+ if State.ws then
+ Network.send({
+ type = "heartbeat",
+ stationName = State.stationName,
+ stationCode = State.stationCode,
+ stationPlatform = State.stationPlatform
+ })
+ end
+ os.sleep(10)
+ end
+end
+
+-- ============================================================================
+-- 7. Display System
+-- ============================================================================
+local Display = {}
+
+function Display.render()
+ local mon = Peripherals.primaryMonitor
+ local w, h = mon.getSize()
+ mon.clear()
+ mon.setCursorPos(1,1)
+ mon.setTextColor(COLORS.INFO)
+ mon.write("STATION: " .. State.stationName)
+ mon.setCursorPos(1,2)
+ mon.write("TIME: " .. os.date("%H:%M:%S"))
+
+ local function classifyAudioSource(source, priority)
+ local s = tostring(source or ""):lower()
+ if s == "train" or s == "station" or s == "broadcast" then return "Station Announcement" end
+ if s ~= "" then return "Special Announcement(" .. tostring(source) .. ")" end
+ if (tonumber(priority) or 0) >= 2 then return "Special Announcement" end
+ return "Special Announcement"
+ end
+
+ local function trunc(str)
+ str = tostring(str or "")
+ if #str > w then return str:sub(1, w) end
+ return str
+ end
+
+ mon.setCursorPos(1,3)
+ if State.currentPlaying then
+ local p = tonumber(State.currentPlaying.priority) or 0
+ local kind = classifyAudioSource(State.currentPlaying.source, p)
+ mon.setTextColor(COLORS.SUCCESS)
+ mon.write(trunc("AUDIO: " .. kind .. " Playing (P=" .. tostring(p) .. ", Q=" .. tostring(#State.audioQueue) .. ")"))
+ elseif State.audioRx then
+ local p = tonumber(State.audioRx.priority) or 0
+ local kind = classifyAudioSource(State.audioRx.source, p)
+ local rx = tonumber(State.audioRx.receivedBytes) or 0
+ local total = tonumber(State.audioRx.expectedBytes) or 0
+ local pct = ""
+ if total > 0 then pct = " " .. tostring(math.floor((rx / total) * 100)) .. "%" end
+ mon.setTextColor(COLORS.DEBUG)
+ mon.write(trunc("AUDIO: " .. kind .. " Receiving" .. pct .. " (Q=" .. tostring(#State.audioQueue) .. ")"))
+ else
+ mon.setTextColor(COLORS.INFO)
+ mon.write(trunc("AUDIO: FREE (Q=" .. tostring(#State.audioQueue) .. ")"))
+ end
+
+ -- Current Train
+ if State.currentTrain then
+ mon.setCursorPos(1,4)
+ mon.setTextColor(COLORS.SUCCESS)
+ mon.write("CURRENT TRAIN:")
+ mon.setTextColor(COLORS.INFO)
+ mon.setCursorPos(1,5)
+ mon.write(State.currentTrain.id)
+ mon.setCursorPos(1,6)
+ mon.write("Dest: " .. State.currentTrain.dest)
+
+ -- Countdown
+ if State.currentTrain.departureTime then
+ local remaining = math.max(0, State.currentTrain.departureTime - os.epoch("utc") / 1000)
+ mon.setCursorPos(1, 8)
+ if remaining > 0 then
+ mon.setTextColor(COLORS.WARNING)
+ mon.write(string.format("Departing in: %02d:%02d", math.floor(remaining/60), math.floor(remaining%60)))
+ else
+ mon.setTextColor(COLORS.ERROR)
+ mon.write("Departing...")
+ end
+ end
+ else
+ mon.setCursorPos(1,4)
+ mon.setTextColor(COLORS.WARNING)
+ mon.write("NO TRAIN AT PLATFORM")
+ end
+
+ -- Logs
+ local y = h - 3
+ for i = 1, 3 do
+ local logIdx = i + State.logScrollOffset
+ local entry = State.logs[logIdx]
+ if entry then
+ mon.setCursorPos(1, y + i - 1)
+ mon.setTextColor(COLORS[entry.level] or colors.white)
+ mon.write(entry.text:sub(1, w))
+ end
+ end
+end
+
+function Display.renderTerminal()
+ local w, h = term.getSize()
+
+ -- Header
+ term.setCursorPos(1, 1)
+ term.setBackgroundColor(colors.gray)
+ term.setTextColor(colors.white)
+ term.clearLine()
+ local title = " Station Broadcast Console (" .. State.stationName .. ") "
+ term.setCursorPos(math.floor((w - #title)/2) + 1, 1)
+ term.write(title)
+ term.setBackgroundColor(colors.black)
+ term.setCursorPos(1, 2)
+ term.setTextColor(colors.gray)
+ term.write(string.rep("-", w))
+
+ -- Logs
+ local logStart = 3
+ local maxLogs = h - logStart + 1
+ for i = 1, maxLogs do
+ local logIdx = i + State.logScrollOffset
+ term.setCursorPos(1, logStart + i - 1)
+ term.clearLine()
+ local entry = State.logs[logIdx]
+ if entry then
+ term.setTextColor(COLORS[entry.level] or colors.white)
+ term.write(entry.text:sub(1, w - 1))
+ end
+ end
+
+ -- Scroll Indicator
+ if State.logScrollOffset > 0 then
+ term.setCursorPos(w, logStart)
+ term.setTextColor(colors.yellow)
+ term.write("^")
+ end
+ if #State.logs > State.logScrollOffset + maxLogs then
+ term.setCursorPos(w, h)
+ term.setTextColor(colors.yellow)
+ term.write("v")
+ end
+end
+
+-- ============================================================================
+-- 7.5 Input Handling
+-- ============================================================================
+local Input = {}
+
+function Input.listener()
+ Utils.log("DEBUG", "Input listener started")
+ local monitorName = peripheral.getName(Peripherals.primaryMonitor)
+
+ while true do
+ local event, param1, param2, param3 = os.pullEvent()
+ local _, h = term.getSize()
+ local maxLogs = h - 5 + 1
+
+ if event == "mouse_scroll" then
+ local direction = param1
+ if direction == -1 then -- Scroll Up
+ State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - maxLogs))
+ elseif direction == 1 then -- Scroll Down
+ State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
+ end
+ Display.renderTerminal()
+ Display.render()
+ elseif event == "monitor_scroll" and param1 == monitorName then
+ local direction = param2
+ if direction == -1 then
+ State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 3))
+ elseif direction == 1 then
+ State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
+ end
+ Display.renderTerminal()
+ Display.render()
+ elseif event == "monitor_touch" and param1 == monitorName then
+ -- Simple touch to scroll: top half of log area scrolls up, bottom half scrolls down
+ local _, monH = Peripherals.primaryMonitor.getSize()
+ local y = param3
+ if y >= monH - 3 then
+ if y == monH - 3 then -- Top line of log area
+ State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 3))
+ elseif y == monH then -- Bottom line of log area
+ State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
+ end
+ Display.renderTerminal()
+ Display.render()
+ end
+ elseif event == "key" then
+ if param1 == keys.up then
+ State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - maxLogs))
+ elseif param1 == keys.down then
+ State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
+ elseif param1 == keys.pageUp then
+ State.logScrollOffset = math.min(State.logScrollOffset + 10, math.max(0, #State.logs - maxLogs))
+ elseif param1 == keys.pageDown then
+ State.logScrollOffset = math.max(0, State.logScrollOffset - 10)
+ elseif param1 == keys.home then
+ State.logScrollOffset = 0
+ elseif param1 == keys["end"] then
+ State.logScrollOffset = math.max(0, #State.logs - maxLogs)
+ end
+ Display.renderTerminal()
+ Display.render()
+ end
+ end
+end
+
+-- ============================================================================
+-- 8. Main Logic
+-- ============================================================================
+local Main = {}
+
+function Main.loop()
+ while true do
+ local hasTrain = Peripherals.station.isTrainPresent()
+
+ if hasTrain then
+ if not State.currentTrain then
+ -- Train just arrived
+ local id = Peripherals.station.getTrainName()
+ local schedule = Peripherals.station.getSchedule()
+ local dest = Utils.getDestination(schedule)
+ local now = os.epoch("utc") / 1000
+ local stationTime = Utils.getStopTime(schedule, State.stationName) or 60
+
+ State.currentTrain = {
+ id = id,
+ trainType = Utils.parseTrainId(id),
+ schedule = schedule,
+ dest = dest,
+ arrivalTime = now,
+ departureTime = now + stationTime,
+ platform = Utils.getPlatformNumber(State.stationName, "1")
+ }
+
+ Network.send({
+ type = "train_arrival",
+ trainId = id,
+ stationName = State.stationName,
+ destination = dest
+ })
+
+ if not State.arrivalTonePlayed then
+ if stationTime < 50 then
+ Utils.log("INFO", "Short stop (<50s) and no pre-arrival. Skipping arrival announcement.")
+ State.shortStopMode = true
+ else
+ Utils.log("INFO", "Train arrived. Playing arrival announcement (alt).")
+ Audio.announceArrival(State.currentTrain.id, State.currentTrain.trainType, State.currentTrain.platform, State.currentTrain.dest, true)
+ end
+ State.arrivalTonePlayed = true
+ end
+ else
+ -- Train currently at station
+ local now = os.epoch("utc") / 1000
+ local remaining = State.currentTrain.departureTime - now
+
+ if State.shortStopMode then
+ -- Play fcts.wav only in the last 10 seconds
+ if remaining <= 10 and not State.departingPlayed then
+ Utils.log("INFO", "Short stop mode: playing fcts.wav only.")
+ Audio.play({"fcts"})
+ State.departingPlayed = true
+ end
+ else
+ if remaining <= 50 and not State.departingPlayed then
+ Audio.announceDeparture(State.currentTrain.id, State.currentTrain.trainType, State.currentTrain.platform, State.currentTrain.dest, 60)
+ State.departingPlayed = true
+ end
+ end
+ end
+ else
+ if State.currentTrain then
+ -- Train just left
+ Network.send({
+ type = "train_departure",
+ trainId = State.currentTrain.id,
+ stationName = State.stationName,
+ destination = State.currentTrain.dest,
+ schedule = State.currentTrain.schedule,
+ stationTime = math.floor((os.epoch("utc") / 1000) - State.currentTrain.arrivalTime)
+ })
+ State.currentTrain = nil
+ State.arrivalTonePlayed = false
+ State.departingPlayed = false
+ State.shortStopMode = false
+ end
+ end
+
+ Display.render()
+ Display.renderTerminal()
+ os.sleep(0.5)
+ end
+end
+
+-- ============================================================================
+-- 9. Bootstrap
+-- ============================================================================
+Peripherals.init()
+Audio.loadStationList()
+
+parallel.waitForAny(
+ function()
+ while true do
+ if not State.ws then Network.connect() end
+ os.sleep(5)
+ end
+ end,
+ Audio.playerLoop,
+ Network.messageLoop,
+ Network.heartbeat,
+ Network.syncFiles,
+ Input.listener,
+ Main.loop
+)
diff --git a/update_station_broadcast.lua b/update_station_broadcast.lua
new file mode 100644
index 0000000..c2747c8
--- /dev/null
+++ b/update_station_broadcast.lua
@@ -0,0 +1,82 @@
+local STATION_BROADCAST_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/station_broadcast.lua?sign=xXsMwezjnEs4c6QrjkJkbvz1rARokmm87zwDcDDS5dk=:0"
+local AUKIT_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/aukit.lua?sign=02_QDktYbSrHrtCDsFlKMkbEOWJGDautZaOtWkvg5q8=:0"
+local AUSTREAM_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/austream.lua?sign=DrLbf9CIX1ZY8DaL8vliZiCMQzGos10nhG26Wf5lbgA=:0"
+
+local function fetch(url)
+ if not http then
+ return nil, "HTTP API unavailable"
+ end
+
+ if http.checkURL then
+ local ok, err = http.checkURL(url)
+ if not ok then
+ return nil, err or "URL blocked"
+ end
+ end
+
+ local res, err = http.get(url)
+ if not res then
+ return nil, err or "request failed"
+ end
+
+ local body = res.readAll()
+ res.close()
+
+ if type(body) ~= "string" or #body == 0 then
+ return nil, "empty response"
+ end
+
+ return body
+end
+
+local function writeFile(path, content)
+ local dir = fs.getDir(path)
+ if dir and dir ~= "" and not fs.exists(dir) then
+ fs.makeDir(dir)
+ end
+
+ local f = fs.open(path, "w")
+ if not f then
+ return nil, "cannot open " .. path
+ end
+
+ f.write(content)
+ f.close()
+ return true
+end
+
+local function downloadTo(url, path)
+ write(("Updating %s... "):format(path))
+ local body, err = fetch(url)
+ if not body then
+ print("FAILED")
+ print(err)
+ return false
+ end
+
+ local ok, werr = writeFile(path, body)
+ if not ok then
+ print("FAILED")
+ print(werr)
+ return false
+ end
+
+ print("OK")
+ return true
+end
+
+local targets = {
+ { url = STATION_BROADCAST_URL, path = shell.resolve("/startup") },
+ { url = AUKIT_URL, path = shell.resolve("/aukit.lua") },
+ { url = AUSTREAM_URL, path = shell.resolve("/austream.lua") }
+}
+
+for _, t in ipairs(targets) do
+ local ok = downloadTo(t.url, t.path)
+ if not ok then
+ print("Update aborted.")
+ return
+ end
+end
+
+print("Update complete.")