回声碎语 Echo Snippet · English Radio Segments

📴 这是离线单文件版:不需要服务器和网络,在手机上直接打开就能读稿子。下载 .docx 需要联网版的网盘/电脑端。

ON AIR · 校园英语广播 · 模块化播出

挑几个模块,
拼成一期节目。

22 个独立英语模块,每个 2–5 分钟,谁都能念。
每期只选其中几个播出——课间十分钟、午间一刻钟、周五放学,随你拼。

  • 个模块
  • 英文词
  • 分钟素材
  • 份串联单
ON AIR 0:00RUNDOWN 待编排
0 SEGMENTS
拖动指针 · 查看时间轴

串联单是空的

先挑几个模块 →

在下面的模块库里点「+」,或直接载入一份现成的串联单。

拖动刻度盘 = 在节目时间轴上滑动; 逐模块移动,Enter 进入播音室。

每日两篇 Daily Scripts

现成的串联单

一键载入,再按需增删模块

模块库

串场词库 The Glue Kit

模块之间的衔接语 · 短、慢、说完就放音乐 · 任何同学都能念

怎么排一期节目

01

选模块

模块库按板块筛选。每期挑 3–4 个就够:一个朗读、一个实用、一个走心、一个文化或语言小品。

02

拼串联单

点「+」加入右侧串联单,拖动可换顺序。右侧时长条会实时显示总时长,超出目标会变红。

03

串场

模块之间需要一两句话衔接。直接念串场词库里的句子,或者改成你自己的说法——但别超过十秒。

04

全员参与

每个模块都标了可以几个人念。[ANY] 任何人、[A][B][C] 可互换、[ALL] 大家一起。人数不够就删掉几行。

05

对稿与提词

播音室里可以按角色高亮自己的台词、逐句走棒,或者开提词器自动滚动。字号、速度都能调。

06

加新模块

segments/ 里新建一个 .md,照 _TEMPLATE.md 的格式写,运行 node tools/build-data.mjs 就自动上架。

EchoQR.matrix('http://192.168.0.4:8080/') → { size, modules, version, mask, ecLevel } EchoQR.svg('http://192.168.0.4:8080/') → SVG 字符串 把 SVG 塞进 innerHTML 即可显示。 用法(Node): require 不行(不是模块),但可以直接在 Node 里跑: const fs = require('fs'); eval(fs.readFileSync('assets/qr.js','utf8')); globalThis.EchoQR.svg('hello') 或用 vm.runInThisContext(tools/qr-selftest.mjs 就是这么做的)。 实现范围: · 字节模式(UTF-8),纠错等级 M · 版本 1–10,按数据量自动选最小可用版本 · 8 种掩码全实现,按标准罚分规则(N1=3 N2=3 N3=40 N4=10)自动择优 · 定位图形 / 分隔符 / 定时图形 / 校正图形 / 固定黑点 / 两处格式信息(BCH(15,5) + 0x5412)/ 版本信息(BCH(18,6),v≥7) · 无任何外部依赖,无 eval,无网络请求 规范依据:ISO/IEC 18004(QR Code Model 2)。所有常量表均来自该标准的 容量表、纠错分块表、校正图形坐标表、格式信息表与版本信息表。 ========================================================================== */ (function (root) { 'use strict'; /* ====================================================================== 1. GF(256) 伽罗华域算术 本原多项式 x^8 + x^4 + x^3 + x^2 + 1 = 0x11D,生成元 α = 2 ====================================================================== */ var EXP = new Uint8Array(512); // EXP[i] = α^i var LOG = new Uint8Array(256); // LOG[x] = i 使 α^i = x (function initGaloisField() { var x = 1; for (var i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; } for (var j = 255; j < 512; j++) EXP[j] = EXP[j - 255]; })(); function gfMul(a, b) { if (a === 0 || b === 0) return 0; return EXP[LOG[a] + LOG[b]]; } /* ====================================================================== 2. Reed–Solomon 纠错 ====================================================================== */ /** 生成多项式 g(x) = (x-α^0)(x-α^1)...(x-α^(n-1)),系数按降幂存放 */ function rsGenerator(n) { var g = [1]; for (var i = 0; i < n; i++) { var next = new Array(g.length + 1); for (var k = 0; k < next.length; k++) next[k] = 0; for (var j = 0; j < g.length; j++) { next[j] ^= g[j]; // 乘 x(次数 +1) next[j + 1] ^= gfMul(g[j], EXP[i]); // 乘 α^i } g = next; } return g; } var GEN_CACHE = {}; function generatorFor(n) { if (!GEN_CACHE[n]) GEN_CACHE[n] = rsGenerator(n); return GEN_CACHE[n]; } /** 对 data 求 n 个纠错码字(多项式长除法取余) */ function rsEncode(data, n) { var gen = generatorFor(n); var buf = new Array(data.length + n); var i, j; for (i = 0; i < data.length; i++) buf[i] = data[i]; for (i = data.length; i < buf.length; i++) buf[i] = 0; for (i = 0; i < data.length; i++) { var coef = buf[i]; if (coef !== 0) { for (j = 0; j < gen.length; j++) buf[i + j] ^= gfMul(gen[j], coef); } } return buf.slice(data.length); } /* ====================================================================== 3. 标准常量表(ISO/IEC 18004) ====================================================================== */ /** 每个版本的总码字数(v1–v10) */ var TOTAL_CODEWORDS = [26, 44, 70, 100, 134, 172, 196, 242, 292, 346]; /** 纠错等级 M 的分块结构 [每块纠错码字数, [[块数, 每块数据码字数], ...]] */ var EC_BLOCKS_M = { 1: [10, [[1, 16]]], 2: [16, [[1, 28]]], 3: [26, [[1, 44]]], 4: [18, [[2, 32]]], 5: [24, [[2, 43]]], 6: [16, [[4, 27]]], 7: [18, [[4, 31]]], 8: [22, [[2, 38], [2, 39]]], 9: [22, [[3, 36], [2, 37]]], 10: [26, [[4, 43], [1, 44]]] }; /** 校正图形中心坐标(v1 没有校正图形) */ var ALIGN_POS = [ [], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50] ]; /** 末尾剩余位(补齐用,不算码字) */ var REMAINDER_BITS = [0, 7, 7, 7, 7, 7, 0, 0, 0, 0]; var MIN_VERSION = 1; var MAX_VERSION = 10; var EC_LEVEL_M_BITS = 0; // 纠错等级 M 的 2 位编码是 00(L=01, M=00, Q=11, H=10) /** 某版本的数据码字总数 */ function dataCodewordCount(version) { var spec = EC_BLOCKS_M[version]; var total = 0; for (var i = 0; i < spec[1].length; i++) total += spec[1][i][0] * spec[1][i][1]; return total; } /** 字节模式下某版本能装的字节数:扣掉 4 位模式 + 字符计数位 */ function byteCapacity(version) { var ccBits = version <= 9 ? 8 : 16; return Math.floor((dataCodewordCount(version) * 8 - 4 - ccBits) / 8); } /* ====================================================================== 4. BCH:格式信息与版本信息 ====================================================================== */ function bitLength(n) { var c = 0; while (n) { c++; n >>>= 1; } return c; } /** * 格式信息:2 位纠错等级 + 3 位掩码 → 15 位 BCH(15,5),再异或 0x5412。 * 生成多项式 0x537 = x^10+x^8+x^5+x^4+x^2+x+1 */ function formatBits(ecBits, mask) { var data = ((ecBits & 3) << 3) | (mask & 7); var d = data << 10; while (bitLength(d) - bitLength(0x537) >= 0) { d ^= 0x537 << (bitLength(d) - bitLength(0x537)); } return (((data << 10) | d) ^ 0x5412) & 0x7fff; } /** * 版本信息(版本 ≥ 7):6 位版本号 → 18 位 BCH(18,6)。 * 生成多项式 0x1F25 = x^12+x^11+x^10+x^9+x^8+x^5+x^2+1 */ function versionBits(version) { var d = version << 12; while (bitLength(d) - bitLength(0x1f25) >= 0) { d ^= 0x1f25 << (bitLength(d) - bitLength(0x1f25)); } return (version << 12) | d; } /* ====================================================================== 5. 8 种数据掩码 ====================================================================== */ function maskAt(pattern, y, x) { switch (pattern) { case 0: return (y + x) % 2 === 0; case 1: return y % 2 === 0; case 2: return x % 3 === 0; case 3: return (y + x) % 3 === 0; case 4: return (Math.floor(y / 2) + Math.floor(x / 3)) % 2 === 0; case 5: return ((y * x) % 2) + ((y * x) % 3) === 0; case 6: return (((y * x) % 2) + ((y * x) % 3)) % 2 === 0; case 7: return (((y * x) % 3) + ((y + x) % 2)) % 2 === 0; default: throw new Error('EchoQR: 掩码编号必须是 0–7,收到 ' + pattern); } } /* ====================================================================== 6. 掩码罚分(N1=3, N2=3, N3=40, N4=10) ====================================================================== */ var N1 = 3, N2 = 3, N3 = 40, N4 = 10; /** 1:1:3:1:1 且前后带 4 个浅色模块的两种 11 位图样 */ var PATTERN_A = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0]; var PATTERN_B = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1]; function penaltyScore(m) { var size = m.length; var score = 0; var i, j; /* --- N1:行 / 列中长度 ≥5 的同色连续段,罚 3 + (len - 5) --- */ for (i = 0; i < size; i++) { var runRow = 1, runCol = 1; for (j = 1; j < size; j++) { if (m[i][j] === m[i][j - 1]) runRow++; else { if (runRow >= 5) score += N1 + (runRow - 5); runRow = 1; } if (m[j][i] === m[j - 1][i]) runCol++; else { if (runCol >= 5) score += N1 + (runCol - 5); runCol = 1; } } if (runRow >= 5) score += N1 + (runRow - 5); if (runCol >= 5) score += N1 + (runCol - 5); } /* --- N2:2×2 同色方块,每块罚 3 --- */ for (i = 0; i < size - 1; i++) { for (j = 0; j < size - 1; j++) { var c = m[i][j]; if (c === m[i][j + 1] && c === m[i + 1][j] && c === m[i + 1][j + 1]) score += N2; } } /* --- N3:出现 1:1:3:1:1 图样且一侧有 4 个浅色模块,每次罚 40 --- */ for (i = 0; i < size; i++) { for (j = 0; j + 11 <= size; j++) { var rowA = true, rowB = true, colA = true, colB = true; for (var k = 0; k < 11; k++) { var rv = m[i][j + k] ? 1 : 0; var cv = m[j + k][i] ? 1 : 0; if (rv !== PATTERN_A[k]) rowA = false; if (rv !== PATTERN_B[k]) rowB = false; if (cv !== PATTERN_A[k]) colA = false; if (cv !== PATTERN_B[k]) colB = false; } if (rowA || rowB) score += N3; if (colA || colB) score += N3; } } /* --- N4:深色比例偏离 50%,每 5% 罚 10 --- */ var dark = 0; for (i = 0; i < size; i++) for (j = 0; j < size; j++) if (m[i][j]) dark++; var percent = (dark * 100) / (size * size); score += Math.floor(Math.abs(percent - 50) / 5) * N4; return score; } /* ====================================================================== 7. 符号布局 ====================================================================== */ function newMatrix(size) { var m = new Array(size); for (var i = 0; i < size; i++) { var row = new Array(size); for (var j = 0; j < size; j++) row[j] = null; // null = 尚未占用的数据模块 m[i] = row; } return m; } /** 定位图形 + 分隔符:以 (row, col) 为左上角的 7×7 图形,外圈留 1 格浅色分隔符 */ function placeFinder(m, row, col) { var size = m.length; for (var r = -1; r <= 7; r++) { for (var c = -1; c <= 7; c++) { var rr = row + r, cc = col + c; if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue; var inRing = (r >= 0 && r <= 6 && (c === 0 || c === 6)) || (c >= 0 && c <= 6 && (r === 0 || r === 6)); var inCore = (r >= 2 && r <= 4 && c >= 2 && c <= 4); m[rr][cc] = inRing || inCore; } } } /** 定时图形:第 6 行与第 6 列,坐标偶数处为深色 */ function placeTiming(m) { var size = m.length; for (var i = 8; i <= size - 9; i++) { m[6][i] = (i % 2 === 0); m[i][6] = (i % 2 === 0); } } /** 校正图形:5×5,外圈深、中圈浅、中心深;与定位图形重叠的三个不画 */ function placeAlignment(m, version) { var size = m.length; var pos = ALIGN_POS[version - 1]; if (!pos.length) return; for (var a = 0; a < pos.length; a++) { for (var b = 0; b < pos.length; b++) { var r = pos[a], c = pos[b]; if ((r === 6 && c === 6) || (r === 6 && c === size - 7) || (r === size - 7 && c === 6)) continue; for (var dr = -2; dr <= 2; dr++) { for (var dc = -2; dc <= 2; dc++) { m[r + dr][c + dc] = Math.max(Math.abs(dr), Math.abs(dc)) !== 1; } } } } } /** 预留格式信息区(15+15 位),先占位成浅色,稍后写真实值 */ function reserveFormat(m) { var size = m.length, i; for (i = 0; i < 9; i++) { if (m[8][i] === null) m[8][i] = false; if (m[i][8] === null) m[i][8] = false; } for (i = 0; i < 8; i++) { if (m[8][size - 1 - i] === null) m[8][size - 1 - i] = false; if (m[size - 1 - i][8] === null) m[size - 1 - i][8] = false; } } /** 预留版本信息区(v ≥ 7):右上 6×3 与左下 3×6 */ function reserveVersion(m, version) { if (version < 7) return; var size = m.length, i; for (i = 0; i < 18; i++) { var r = Math.floor(i / 3), c = (i % 3) + size - 11; if (m[r][c] === null) m[r][c] = false; if (m[c][r] === null) m[c][r] = false; } } /** 写入 15 位格式信息(两处,内容相同) */ function writeFormat(m, ecBits, mask) { var size = m.length; var bits = formatBits(ecBits, mask); for (var i = 0; i < 15; i++) { var bit = ((bits >> i) & 1) === 1; if (i < 6) m[i][8] = bit; else if (i < 8) m[i + 1][8] = bit; else m[size - 15 + i][8] = bit; if (i < 8) m[8][size - i - 1] = bit; else if (i === 8) m[8][7] = bit; else m[8][15 - i - 1] = bit; } m[size - 8][8] = true; // 固定黑点 } /** 写入 18 位版本信息(两处,内容相同) */ function writeVersion(m, version) { if (version < 7) return; var size = m.length; var bits = versionBits(version); for (var i = 0; i < 18; i++) { var bit = ((bits >> i) & 1) === 1; m[Math.floor(i / 3)][(i % 3) + size - 11] = bit; m[(i % 3) + size - 11][Math.floor(i / 3)] = bit; } } /* ====================================================================== 8. 数据编码 ====================================================================== */ /** JS 字符串 → UTF-8 字节(自己实现,不依赖 TextEncoder) */ function utf8Bytes(str) { var out = []; for (var i = 0; i < str.length; i++) { var c = str.charCodeAt(i); if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) { var lo = str.charCodeAt(i + 1); if (lo >= 0xdc00 && lo <= 0xdfff) { c = 0x10000 + ((c - 0xd800) << 10) + (lo - 0xdc00); i++; } } if (c < 0x80) { out.push(c); } else if (c < 0x800) { out.push(0xc0 | (c >> 6), 0x80 | (c & 63)); } else if (c < 0x10000) { out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63)); } else { out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63)); } } return out; } /** 选最小可用版本;装不下就报错 */ function pickVersion(byteLen) { for (var v = MIN_VERSION; v <= MAX_VERSION; v++) { if (byteLen <= byteCapacity(v)) return v; } throw new Error('EchoQR: 内容太长(' + byteLen + ' 字节),本实现最大支持版本 ' + MAX_VERSION + '(' + byteCapacity(MAX_VERSION) + ' 字节,纠错等级 M)。请缩短文本或改用短链接。'); } /** 生成最终交错的完整码字序列(数据码字 + 纠错码字) */ function buildCodewords(bytes, version) { var spec = EC_BLOCKS_M[version]; var ecLen = spec[0]; var totalData = dataCodewordCount(version); var bitCap = totalData * 8; var ccBits = version <= 9 ? 8 : 16; /* --- 位流:模式指示符 0100 + 字符计数 + 数据 --- */ var bits = []; function pushBits(value, width) { for (var k = width - 1; k >= 0; k--) bits.push((value >> k) & 1); } pushBits(4, 4); // 字节模式 pushBits(bytes.length, ccBits); for (var i = 0; i < bytes.length; i++) pushBits(bytes[i], 8); /* --- 终止符(最多 4 个 0,且不超容量) --- */ var term = Math.min(4, bitCap - bits.length); for (var t = 0; t < term; t++) bits.push(0); /* --- 补齐到字节边界 --- */ while (bits.length % 8 !== 0) bits.push(0); /* --- 交替填充 0xEC / 0x11 --- */ var pad = [0xec, 0x11], p = 0; while (bits.length < bitCap) { pushBits(pad[p++ % 2], 8); } /* --- 位流 → 数据码字 --- */ var dataCw = []; for (var b = 0; b < bits.length; b += 8) { var byteVal = 0; for (var q = 0; q < 8; q++) byteVal = (byteVal << 1) | bits[b + q]; dataCw.push(byteVal); } /* --- 按标准分块并计算纠错码字 --- */ var blocks = [], offset = 0, gi, bi; for (gi = 0; gi < spec[1].length; gi++) { var numBlocks = spec[1][gi][0], len = spec[1][gi][1]; for (bi = 0; bi < numBlocks; bi++) { var chunk = dataCw.slice(offset, offset + len); offset += len; blocks.push({ data: chunk, ec: rsEncode(chunk, ecLen) }); } } /* --- 交错:先按列取所有数据码字,再按列取所有纠错码字 --- */ var out = []; var maxData = 0; for (bi = 0; bi < blocks.length; bi++) maxData = Math.max(maxData, blocks[bi].data.length); for (i = 0; i < maxData; i++) { for (bi = 0; bi < blocks.length; bi++) { if (i < blocks[bi].data.length) out.push(blocks[bi].data[i]); } } for (i = 0; i < ecLen; i++) { for (bi = 0; bi < blocks.length; bi++) out.push(blocks[bi].ec[i]); } return out; } /* ====================================================================== 9. 把码字铺进矩阵(标准 Z 字形走法) ====================================================================== */ function placeData(m, version, codewords, mask) { var size = m.length; var totalBits = codewords.length * 8; var bitIndex = 0; var inc = -1; var row = size - 1; for (var col = size - 1; col > 0; col -= 2) { if (col === 6) col = 5; // 跳过第 6 列(竖向定时图形) for (;;) { for (var c = 0; c < 2; c++) { var cc = col - c; if (m[row][cc] === null) { var dark = false; if (bitIndex < totalBits) { dark = ((codewords[bitIndex >> 3] >>> (7 - (bitIndex & 7))) & 1) === 1; } if (maskAt(mask, row, cc)) dark = !dark; // 施加掩码 m[row][cc] = dark; bitIndex++; } } row += inc; if (row < 0 || row >= size) { row -= inc; inc = -inc; break; } } } return bitIndex; } /* ====================================================================== 10. 生成一个完整符号 ====================================================================== */ function makeSymbol(version, mask, codewords) { var size = version * 4 + 17; var m = newMatrix(size); placeFinder(m, 0, 0); placeFinder(m, 0, size - 7); placeFinder(m, size - 7, 0); placeTiming(m); placeAlignment(m, version); reserveFormat(m); reserveVersion(m, version); m[size - 8][8] = true; // 固定黑点 placeData(m, version, codewords, mask); writeFormat(m, EC_LEVEL_M_BITS, mask); writeVersion(m, version); return m; } /* ====================================================================== 11. 对外 API ====================================================================== */ /** * 生成二维码矩阵 * @param {string} text 任意文本(按 UTF-8 编码) * @returns {{size:number, modules:boolean[][], version:number, mask:number, ecLevel:string}} * modules[y][x] === true 表示该模块为深色 */ function matrix(text) { if (text === undefined || text === null) throw new Error('EchoQR: text 不能为空'); var str = String(text); var bytes = utf8Bytes(str); if (!bytes.length) throw new Error('EchoQR: text 不能是空字符串'); var version = pickVersion(bytes.length); var codewords = buildCodewords(bytes, version); /* 8 种掩码全部试一遍,按标准罚分取最低分(同分取编号小的) */ var best = null; for (var mask = 0; mask < 8; mask++) { var m = makeSymbol(version, mask, codewords); var score = penaltyScore(m); if (!best || score < best.score) best = { mask: mask, score: score, modules: m }; } return { size: best.modules.length, modules: best.modules, version: version, mask: best.mask, ecLevel: 'M', penalty: best.score }; } function escapeXml(s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } /** * 生成 SVG 字符串(含静默区) * @param {string} text * @param {{margin?:number, dark?:string, light?:string}} [opts] * margin 默认 4(标准要求的静默区宽度,单位=模块) * @returns {string} SVG 源码,可直接 innerHTML */ function svg(text, opts) { opts = opts || {}; var margin = opts.margin === undefined || opts.margin === null ? 4 : Number(opts.margin); if (!(margin >= 0)) margin = 4; var dark = opts.dark || '#000'; var light = opts.light || '#fff'; var m = matrix(text); var size = m.size; var dim = size + margin * 2; /* 每一行的连续深色模块合并成一条横线,放进同一个 path 的 d 属性 */ var d = ''; for (var y = 0; y < size; y++) { var x = 0; while (x < size) { if (!m.modules[y][x]) { x++; continue; } var run = 1; while (x + run < size && m.modules[y][x + run]) run++; d += 'M' + (x + margin) + ' ' + (y + margin) + 'h' + run + 'v1h-' + run + 'z'; x += run; } } return '' + '' + '' + ''; } root.EchoQR = { matrix: matrix, svg: svg, /* 以下为内部实现,供自检脚本与调试使用,不属于稳定 API */ _internal: { EXP: EXP, LOG: LOG, gfMul: gfMul, rsGenerator: rsGenerator, rsEncode: rsEncode, formatBits: formatBits, versionBits: versionBits, maskAt: maskAt, penaltyScore: penaltyScore, byteCapacity: byteCapacity, dataCodewordCount: dataCodewordCount, utf8Bytes: utf8Bytes, makeSymbol: makeSymbol, buildCodewords: buildCodewords, TOTAL_CODEWORDS: TOTAL_CODEWORDS, EC_BLOCKS_M: EC_BLOCKS_M, ALIGN_POS: ALIGN_POS, REMAINDER_BITS: REMAINDER_BITS, MAX_VERSION: MAX_VERSION } }; })(typeof globalThis !== 'undefined' ? globalThis : this);