// 画面。初代の 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();
});
}