// 画面。初代の TPS-L2 を手本にした本体を1台、真ん中に置く。名前とロゴは借りない // 入口は2つ。?demo なら見本のテープ、そうでなければ本番のテープ(Worker)。Worker に届かなければ入口の案内を出す import { Player, clock } from "./tape.js"; import { SLOT } from "./am.js"; import { record } from "./recorder.js"; import { demoSource } from "./demo.js"; import { liveSource } from "./live.js"; import * as atomo from "./atomo.js"; import { ring, watch, breathe, rest } from "./atomo-ring.js"; const params = new URLSearchParams(location.search); const DEMO = params.has("demo"); const app = document.getElementById("app"); function $(tag, attrs = {}, ...kids) { const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k.startsWith("on")) el.addEventListener(k.slice(2), v); else if (v !== false && v != null) el.setAttribute(k, v === true ? "" : v); } el.append(...kids.flat().filter((k) => k != null)); return el; } const svg = (html) => { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstChild; }; const today = () => new Date(Date.now() + 9 * 3600_000).toISOString().slice(0, 10); // 日本時間の日付 if (DEMO) start(await demoSource(), demoKit()); else { try { const source = await liveSource(); start(source, await liveKit()); } catch { app.append($("main", { class: "door" }, $("h1", {}, "Tapes"), $("p", {}, "世界に1本の終わらないテープに、みんなが1日1回、その場の声を10秒ずつ録り足していく。"), $("p", { class: "quiet" }, "いまはテープに届かない。"), $("a", { class: "what", href: "?demo" }, "見本で開く"), presented())); } } // 見本の道。自分の位置もきょうの回数も、このブラウザにだけ控える // 控えの形は [{ i, at }]。i はテープの何本目か、at は録った時刻(前の形の数だけの配列も読む) function demoKit() { const mine = () => { try { return JSON.parse(localStorage.getItem("tapes:demo:mine") ?? "[]") .map((m) => (typeof m === "number" ? { i: m, at: null } : m)); } catch { return []; } }; const used = () => { try { return localStorage.getItem("tapes:demo:day") === today(); } catch { return false; } }; return { demo: true, mine, canRecord: async () => true, forget(i) { try { localStorage.setItem("tapes:demo:mine", JSON.stringify(mine().filter((m) => m.i !== i))); } catch {} }, remember(i, at) { try { localStorage.setItem("tapes:demo:mine", JSON.stringify([...mine(), { i, at }])); localStorage.setItem("tapes:demo:day", today()); } catch {} }, todayText: () => used() ? "きょうの1回は録った(見本なので、もう一度録れる)" : "きょうはあと1回録れる", query: (sec) => `?demo&at=${sec}`, account: null, smallPrint: "これは見本だ。録った声はこのブラウザの中にだけ置く。鳴っている声に名前は出ない。", empty: "録ると、ここに自分の声の位置が残る。", }; } // 本番。自分の位置ときょうの回数は Worker が本人にだけ返す。ログインしていなければ聞くだけだ async function liveKit() { let who = await atomo.start(); let list = []; let left = null; const refresh = async () => { if (!who) { list = []; left = null; return; } try { ({ mine: list, left } = await atomo.mine("/api/mine")); } catch { list = []; left = null; } }; await refresh(); const field = $("input", { type: "text", placeholder: "ハンドル(例:alice.bsky.social)", "aria-label": "Bluesky のハンドル", autocomplete: "username", spellcheck: "false" }); const account = $("section", { class: "account", "aria-label": "ログイン" }); const kit = { demo: false, mine: () => list, remember(i, at) { list = [...list, { i, at }]; if (left != null) left = Math.max(0, left - 1); }, forget(i) { list = list.filter((m) => m.i !== i); }, // 録る前に確かめる。録れないときは一言を返す async canRecord() { if (!who) { field.focus(); return "録るには、下の欄から ATOMO(Bluesky のアカウント)でログインしてくれ"; } await refresh(); if (left === 0) return "きょうの1回はもう録った。日本時間の0時にまた録れる"; return true; }, todayText: () => !who ? "聞くだけならログインはいらない。録るには ATOMO でログインする" : left === 0 ? "きょうの1回は録った。日本時間の0時にまた録れる" : left == null ? "" : `きょうはあと${left}回録れる`, query: (sec) => `?at=${sec}`, account, smallPrint: "鳴っている声に名前は出ない。自分の声の位置を知っているのは本人だけだ。", empty: who ? "録ると、ここに自分の声の位置が残る。" : "ATOMO でログインすると、ここに自分の声の位置が並ぶ。", }; const draw = () => { if (who) { account.replaceChildren( $("span", {}, who.handle ? `@${who.handle} でログインしている` : "ログインしている"), $("button", { class: "pen", onclick: async () => { await atomo.signOut(); location.reload(); } }, "ログアウト")); } else { const login = $("button", { class: "pen login", type: "submit" }, ring(16), "ATOMO でログイン"); watch(field, login); const go = async (e) => { e.preventDefault(); breathe(login); try { await atomo.signIn(field.value); } catch (err) { rest(login); field.setCustomValidity(String(err?.message ?? err)); field.reportValidity(); } }; field.addEventListener("input", () => field.setCustomValidity("")); account.replaceChildren($("form", { onsubmit: go }, field, login)); } }; draw(); return kit; } async function start(source, kit) { const ui = build(kit); const player = new Player(source, () => paint()); // 開くと最新の60秒前から。位置の URL で開いたらそこから const at = Number(params.get("at")); const told = Number.isFinite(at) && params.has("at"); player.pos = told ? Math.max(0, Math.min(at, player.total)) : Math.max(0, player.total - 60); if (told) ui.note.textContent = `教わった位置 ${clock(player.pos)} から`; let recording = null; // 録っているあいだは残り秒 let cut = null; // 録っているあいだだけ入る。呼ぶとそこで止め、止めたところまでを上げる(2026-11-02、依頼) let msg = null; // 状態の代わりに出す一言(録っている・録れなかった) const say = (m, ms) => { msg = m; paint(); if (ms) setTimeout(() => { if (msg === m) { msg = null; paint(); } }, ms); }; let spin = 0; // 送り戻しで一瞬速く回す ui.keys.forEach(({ el, sec }) => el.addEventListener("click", () => { msg = null; if (sec === 0) return void player.toggle(); spin = sec; player.skip(sec); })); ui.hot.addEventListener("click", rec); document.addEventListener("keydown", (e) => { if (e.target.closest("input, textarea") || e.metaKey || e.ctrlKey || e.altKey || recording != null) return; if (e.key === " ") { e.preventDefault(); player.toggle(); } else if (e.key === "ArrowLeft") { e.preventDefault(); spin = -10; player.skip(-SLOT); } else if (e.key === "ArrowRight") { e.preventDefault(); spin = 10; player.skip(SLOT); } }); async function rec() { if (recording != null) { cut?.(); return; } const ok = await kit.canRecord(); if (ok !== true) { say(ok); return; } const wasPlaying = player.state !== "stopped"; player.stop(); recording = SLOT; say("マイクの許可を待っている"); try { const ctl = {}; const buf = await record((left) => { recording = left; cut = ctl.stop; ui.hotLabel.textContent = `あと ${Math.ceil(left)} 秒`; say(`録っている あと ${Math.ceil(left)} 秒。もう一度押すと、ここで止めて上げる`); }, ctl); cut = null; say(kit.demo ? "AM の音にしている" : "テープに継ぎ足している"); const i = await source.append({ pcm: buf.getChannelData(0).slice() }); kit.remember(i, Date.now()); recording = null; msg = null; drawMemo(i); player.seek(i * SLOT); // 止めたまま自分の位置へ。▶ で自分の声から鳴る if (source.earned) say(`テープに入った。${source.earned} pogala が貯まった`, 6000); } catch (err) { recording = null; cut = null; say(err?.name === "NotAllowedError" ? "マイクを使えなかった。アドレスバーの左の印からこのページにマイクを許し、もう一度オレンジのボタンを押してくれ" : err?.code === "today" ? "きょうの1回はもう録った。日本時間の0時にまた録れる" : err?.code === "full" ? "きょうのテープはみんなの録音で埋まった。日本時間の0時にまた録れる" : err?.code === "end" ? "テープの置き場がいっぱいになった。いまは聞くことだけができる" : `録れなかった(${err?.message ?? err})。もう一度オレンジのボタンを押してくれ`); if (wasPlaying) player.play(); } paint(); } // 自分の位置を画面に出すのは本人の画面だけだ。見本ではこのブラウザに、本番では Worker に控えてある const urlOf = (i) => { const u = new URL(location.href); u.search = kit.query(i * SLOT); u.hash = ""; return u.href; }; // メモの紙。新しい録音が上だ function drawMemo(fresh) { const list = kit.mine().filter((m) => m.i < source.count()).reverse(); ui.memoEmpty.hidden = list.length > 0; ui.memoList.replaceChildren(...list.map((m) => { const sec = m.i * SLOT; const when = m.at ? new Date(m.at).toLocaleString("ja-JP", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; const give = $("button", { class: "pen", "aria-label": `${clock(sec)} の URL を渡す` }, "URL を渡す"); give.addEventListener("click", () => share(urlOf(m.i), give)); const hear = $("button", { class: "pen", "aria-label": `${clock(sec)} から聞く` }, "ここから聞く"); hear.addEventListener("click", () => { player.seek(sec); player.play(); }); // 自分の録音を消す(2026-11-02、依頼)。位置は無音で残るので、渡した URL はそのまま無音を指す const drop = $("button", { class: "pen", "aria-label": `${clock(sec)} の録音を消す` }, "消す"); drop.addEventListener("click", async () => { if (!(await ask(`${clock(sec)} の録音を消す`, "テープのその10秒は無音になり、元には戻せない。きょうの回数も戻らない。", "消す"))) return; drop.disabled = true; try { await source.forget(m.i); kit.forget(m.i); say(`${clock(sec)} の録音を消した`, 4000); drawMemo(); } catch (err) { drop.disabled = false; say(`消せなかった(${err?.message ?? err})`); } }); // 録った日時の欄は外した。ボタン3つと並べると幅が足りず「1…」としか読めなかった(指摘)。日時は時刻に指を置くと出る return $("li", { "data-i": m.i, class: m.i === fresh ? "fresh" : null }, $("span", { class: "pos", title: when ? `${when} に録った` : null }, clock(sec)), hear, give, drop); })); lastHere = -1; } // 渡す:OS の共有があればそれで、なければ URL を写す async function share(url, btn) { try { if (navigator.share) { await navigator.share({ url }); return; } await navigator.clipboard.writeText(url); btn.textContent = "URL を写した"; } catch (err) { if (err?.name === "AbortError") return; prompt("この URL を渡してくれ", url); } setTimeout(() => (btn.textContent = "URL を渡す"), 2000); } // いま鳴っている10秒が自分のものなら、メモのその行に印を付ける let lastHere = -1; function markHere() { const here = player.state === "playing" ? Math.floor(player.now() / SLOT) : -1; if (here === lastHere) return; lastHere = here; for (const li of ui.memoList.children) li.classList.toggle("here", Number(li.dataset.i) === here); } function paint() { const now = player.now(), total = player.total; ui.now.textContent = clock(now); ui.total.textContent = clock(total); ui.play.classList.toggle("down", player.state !== "stopped"); ui.play.setAttribute("aria-label", player.state === "stopped" ? "鳴らす" : "止める"); ui.play.querySelector("span").textContent = player.state === "stopped" ? "▶" : "■"; ui.hot.classList.toggle("live", recording != null); ui.hot.disabled = recording != null && !cut; ui.hot.setAttribute("aria-label", cut ? "ここで止めて上げる" : "録る(10秒)"); ui.today.textContent = kit.todayText(); ui.status.textContent = msg ?? { playing: "鳴っている", waiting: "最新に追いついた。だれかが録るのを待っている", stopped: "止めている。▶ で、いま流れている声を聞ける" }[player.state]; ui.status.classList.toggle("loud", recording != null); // 鳴っているあいだは、文の代わりに波形を出す(2026-11-02、依頼)。文は読み上げ用に残す const wave = player.state === "playing" && msg == null && recording == null; ui.status.classList.toggle("sr", wave); ui.wave.hidden = !wave; if (recording == null) ui.hotLabel.textContent = "10秒録る"; } // リールを回す。左は残り、右は巻き取ったぶんの太さ let a = 0, last = performance.now(); (function frame(t) { const dt = Math.min(0.1, (t - last) / 1000); last = t; const running = player.state === "playing" || recording != null; const speed = (running ? 90 : 0) + spin * 140; spin *= Math.pow(0.02, dt); if (Math.abs(spin) < 0.05) spin = 0; a = (a + speed * dt) % 360; const f = player.total ? player.now() / player.total : 0; ui.reelL.style.transform = `rotate(${a}deg)`; ui.reelR.style.transform = `rotate(${a}deg)`; ui.packL.setAttribute("r", 13 + 17 * (1 - f)); ui.packR.setAttribute("r", 13 + 17 * f); markHere(); if (!ui.wave.hidden) drawWave(ui.wave, player.analyser); if (player.state === "playing") { ui.now.textContent = clock(player.now()); ui.total.textContent = clock(player.total); } requestAnimationFrame(frame); })(last); drawMemo(); paint(); } // ergo・ATOMO Station・ATOMO Status と同じ形の帰属(2026-11-02、指摘) function presented() { return $("p", { class: "quiet small-print presented" }, "Presented by ", $("a", { href: "https://atelier.soiree.blue/", target: "_blank", rel: "noopener" }, "Atelier MoJa"), " · ", $("a", { href: "https://status.soiree.blue/", target: "_blank", rel: "noopener" }, "ATOMO Status"), " · ", $("a", { href: "https://moja.tngl.io/atomo/terms.html", target: "_blank", rel: "noopener" }, "利用規約"), " · ", $("a", { href: "https://moja.tngl.io/atomo/privacy.html", target: "_blank", rel: "noopener" }, "プライバシーポリシー")); } function build(kit) { const keyDefs = [ ["◀◀", "60", -60, "60秒戻す"], ["◀◀", "30", -30, "30秒戻す"], ["◀", "10", -10, "10秒戻す"], ["▶", "", 0, "鳴らす"], ["▶", "10", 10, "10秒進める"], ["▶▶", "30", 30, "30秒進める"], ["▶▶", "60", 60, "60秒進める"], ]; const keys = keyDefs.map(([mark, n, sec, label]) => { const forward = sec > 0; const el = $("button", { class: "key" + (sec === 0 ? " play" : ""), "aria-label": label, title: label }, forward ? [$("b", {}, n), $("span", {}, mark)] : [$("span", {}, mark), n ? $("b", {}, n) : null]); return { el, sec }; }); const play = keys[3].el; const reelSvg = (side) => ` ${[0, 60, 120, 180, 240, 300].map((d) => ``).join("")} `; const cassette = svg(` `); const reels = cassette.querySelectorAll(".reel"); const hot = $("button", { class: "hotline", "aria-label": "録る(10秒)", title: "録る(10秒)" }, $("span", { class: "dot" }, "●")); const hotLabel = $("span", { class: "hot-label" }, "10秒録る"); const jack = () => $("span", { class: "jack", "aria-hidden": "true" }); const now = $("span", { class: "digits" }, "0:00:00"); const total = $("span", { class: "digits" }, "0:00:00"); const status = $("p", { class: "status", role: "status" }, ""); const wave = $("canvas", { class: "wave", "aria-hidden": "true", hidden: true }); const today = $("p", { class: "today" }, ""); const note = $("p", { class: "note" }, ""); const memoList = $("ol", { class: "memo-list", "aria-live": "polite" }); const memoEmpty = $("p", { class: "memo-note" }, kit.empty); const memo = $("section", { class: "memo", "aria-label": "自分の声の位置" }, $("h2", {}, "わたしの声"), $("p", { class: "memo-note" }, "この位置を知っているのは、わたしだけだ。"), memoEmpty, memoList); app.append($("main", { class: "stage" }, $("div", { class: "deck" }, $("div", { class: "keys", role: "group", "aria-label": "送り戻し" }, keys.map((k) => k.el)), $("div", { class: "body" }, $("div", { class: "brow" }, $("div", { class: "hot-wrap" }, hot, $("div", { class: "hot-text" }, $("small", {}, "HOTLINE"), hotLabel)), $("div", { class: "jacks" }, jack(), jack(), $("small", {}, "FOR TWO"))), $("div", { class: "face" }, $("div", { class: "window" }, cassette), $("div", { class: "counter", "aria-label": "テープカウンター" }, $("span", { class: "cell" }, $("small", {}, "いま"), now), $("span", { class: "cell" }, $("small", {}, "全長"), total))), $("div", { class: "foot-strip" }, $("span", {}, "STEREO CASSETTE PLAYER"), $("span", {}, "tapes")))), status, wave, today, kit.account, note, memo, $("p", { class: "quiet small-print" }, kit.smallPrint), kit.demo ? $("p", { class: "quiet small-print credit" }, "見本の声:Blueskyちゃん(", $("a", { href: "https://github.com/kojira/zen-tts-models", rel: "noopener" }, "kojira/zen-tts-models"), ")/音声合成:ZEN-TTS(", $("a", { href: "https://zenryokukikai.com", rel: "noopener" }, "https://zenryokukikai.com"), ")") : null, presented())); return { keys, play, hot, hotLabel, now, total, status, wave, today, note, memo, memoList, memoEmpty, reelL: reels[0], reelR: reels[1], packL: cassette.querySelector(".pack-l"), packR: cassette.querySelector(".pack-r"), }; } // 波形。分析器の時間の波をそのまま線で引く let waveBuf; function drawWave(canvas, analyser) { if (!analyser) return; const dpr = devicePixelRatio || 1; const w = canvas.clientWidth, h = canvas.clientHeight; if (canvas.width !== Math.round(w * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); } waveBuf ??= new Float32Array(analyser.fftSize); analyser.getFloatTimeDomainData(waveBuf); const g = canvas.getContext("2d"); g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, w, h); g.strokeStyle = getComputedStyle(canvas).color; g.lineWidth = 1.5; g.lineJoin = "round"; g.beginPath(); const n = waveBuf.length; for (let k = 0; k < n; k++) { const x = (k / (n - 1)) * w; const y = h / 2 - waveBuf[k] * (h / 2 - 2); k ? g.lineTo(x, y) : g.moveTo(x, y); } g.stroke(); } // 確かめの窓。ブラウザの confirm は画面の上の端に出るので、ページの真ん中に自前で出す(2026-11-02、指摘) function ask(title, body, yes) { return new Promise((done) => { const dlg = document.createElement("dialog"); dlg.className = "ask"; const h = document.createElement("p"); h.className = "ask-title"; h.textContent = title; const t = document.createElement("p"); t.className = "ask-body"; t.textContent = body; const no = document.createElement("button"); no.className = "pen"; no.textContent = "やめる"; no.value = ""; const ok = document.createElement("button"); ok.className = "pen ask-yes"; ok.textContent = yes; ok.value = "yes"; const row = document.createElement("form"); row.method = "dialog"; row.className = "ask-row"; row.append(no, ok); dlg.append(h, t, row); dlg.addEventListener("close", () => { done(dlg.returnValue === "yes"); dlg.remove(); }); dlg.addEventListener("click", (e) => { if (e.target === dlg) dlg.close(""); }); document.body.append(dlg); dlg.showModal(); no.focus(); }); }