soiz1's picture
Update sender.html
f46e070 verified
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<script>
function isAdmin() {
return document.cookie.includes("admin_access=true");
}
if (!isAdmin()) {
// Google Tag Manager
(function(w, d, s, l, i) {
w[l] = w[l] || [];
w[l].push({
'gtm.start': new Date().getTime(),
event: 'gtm.js'
});
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l != 'dataLayer' ? '&l=' + l : '';
j.async = true;
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl;
f.parentNode.insertBefore(j, f);
})(window, document, 'script', 'dataLayer', 'GTM-NDL2LKLQ');
// Google Analytics (gtag.js)
var gtagScript = document.createElement('script');
gtagScript.async = true;
gtagScript.src = "https://www.googletagmanager.com/gtag/js?id=G-2M35HBEEVH";
document.head.appendChild(gtagScript);
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', 'G-2M35HBEEVH');
} else {
console.log("管理者のため、Googleタグはスキップされました。");
}
</script>
<script src="https://unpkg.com/draggabilly@2/dist/draggabilly.pkgd.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
(function() {
const UPDATE_URL = '/update.txt';
const CHECK_INTERVAL_MS = 300000;
let initialVersion = null;
// CSSを挿入
const style = document.createElement('style');
style.textContent = `
#update-popup {
position: fixed;
top: 20px;
right: 20px;
background: #fff3cd;
color: #856304;
border: 1px solid #ffeeba;
border-radius: 8px;
padding: 20px 16px 16px 16px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
display: none;
z-index: 1000;
font-family: sans-serif;
width: 240px;
}
#update-popup-close {
position: absolute;
top: 6px;
right: 8px;
background: none;
border: none;
font-size: 16px;
font-weight: bold;
color: #856404;
cursor: pointer;
}
#update-popup-close:hover {
color: #000;
}
#update-popup button.reload {
margin-top: 10px;
padding: 6px 12px;
border: none;
background-color: #ffc107;
color: #000;
cursor: pointer;
border-radius: 4px;
width: 100%;
}
#update-popup button.reload:hover {
background-color: #e0a800;
}
`;
document.head.appendChild(style);
// ポップアップ要素を作成
const popup = document.createElement('div');
popup.id = 'update-popup';
popup.innerHTML = `
<button id="update-popup-close" aria-label="閉じる">×</button>
<p>ページが新しくなりました。<br>タブの再読み込みが必要です。</p>
<button class="reload">再読み込み</button>
`;
document.body.appendChild(popup);
// 閉じるボタン機能
document.getElementById('update-popup-close').addEventListener('click', () => {
popup.style.display = 'none';
});
// 再読み込みボタン機能
popup.querySelector('.reload').addEventListener('click', () => {
location.reload();
});
// バージョン取得関数
async function fetchVersion() {
try {
const res = await fetch(UPDATE_URL + '?t=' + Date.now());
if (res.ok) return await res.text();
} catch (err) {
console.warn('update.txt の取得に失敗:', err);
}
return null;
}
// 更新チェック関数
async function checkForUpdate() {
const latest = await fetchVersion();
if (initialVersion && latest && latest !== initialVersion) {
popup.style.display = 'block';
}
}
// 初期バージョン取得 → チェック開始
(async () => {
initialVersion = await fetchVersion();
if (initialVersion) {
setInterval(checkForUpdate, CHECK_INTERVAL_MS);
}
})();
})();
})
</script>
<link rel="icon" type="image/svg+xml" href='data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDIwMCAyMDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgbGFuZz0iamEiPjxkZWZzPiA8bGluZWFyR3JhZGllbnQgaWQ9ImdyYWQxIiB4MT0iMCUiIHkxPSIwJSIgeDI9IjAlIiB5Mj0iMTAwJSI+IDxzdG9wIG9mZnNldD0iMCUiIHN0b3AtY29sb3I9IiMzNDc0ZWIiIC8+IDxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iIzM0YThlYiIgLz4gPC9saW5lYXJHcmFkaWVudD4gPC9kZWZzPiA8IS0tIOWQhOS6uuWbvuWkjeS9nOWumuS7tuWkjeaWsOWIl+OBjOaWsOWIl+OBq+OBhOOBq+OBvuOBq+OCi+OCieWbvuWkjeOBjOOCjOOCieOCk+ODq+ODvOODrOODs+ODg+OCiwotLT4gPHBvbHlnb24gcG9pbnRzPSI1MCw1MCA1MCwxNTAgMTUwLDEwMCIgZmlsbD0idXJsKCNncmFkMSkiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS13aWR0aD0iMiIgLz48L3N2Zz4=' />
<meta charset="UTF-8">
<script>
//青いスクロールバーを追加
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("input[type='range']").forEach((slider) => {
const bubble = document.createElement("div");
bubble.style.position = "absolute";
bubble.style.background = "#333a";
bubble.style.color = "#fff";
bubble.style.padding = "4px 8px";
bubble.style.borderRadius = "4px";
bubble.style.fontSize = "12px";
bubble.style.pointerEvents = "none";
bubble.style.whiteSpace = "nowrap";
bubble.style.transform = "translate(-50%, -120%)";
bubble.style.transition = "opacity 0.1s";
bubble.style.opacity = "0";
bubble.style.zIndex = "1000";
document.body.appendChild(bubble);
let rect = null;
slider.addEventListener("mouseenter", () => {
rect = slider.getBoundingClientRect();
bubble.style.opacity = "1";
});
slider.addEventListener("mouseleave", () => {
bubble.style.opacity = "0";
});
slider.addEventListener("mousemove", (e) => {
if (!rect) rect = slider.getBoundingClientRect();
const min = parseFloat(slider.min || 0);
const max = parseFloat(slider.max || 100);
const step = parseFloat(slider.step || 1);
const relativeX = e.clientX - rect.left;
const percent = Math.min(Math.max(relativeX / rect.width, 0), 1);
let value = min + (max - min) * percent;
value = Math.round(value / step) * step;
value = Number(value.toFixed(4)); //小数点以下を丸める(0.140000000000003などの誤差)
// スライダーの値として表示
bubble.textContent = value;
// ツールチップの位置調整
const pageX = e.pageX;
const pageY = window.scrollY + rect.top;
bubble.style.left = `${pageX}px`;
bubble.style.top = `${pageY}px`;
});
window.addEventListener("scroll", () => {
rect = slider.getBoundingClientRect();
});
window.addEventListener("resize", () => {
rect = slider.getBoundingClientRect();
});
});
});
</script>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDIwMCAyMDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgbGFuZz0iamEiPjxkZWZzPiA8bGluZWFyR3JhZGllbnQgaWQ9ImdyYWQxIiB4MT0iMCUiIHkxPSIwJSIgeDI9IjAlIiB5Mj0iMTAwJSI+IDxzdG9wIG9mZnNldD0iMCUiIHN0b3AtY29sb3I9IiMzNDc0ZWIiIC8+IDxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iIzM0YThlYiIgLz4gPC9saW5lYXJHcmFkaWVudD4gPC9kZWZzPiA8IS0tIOWQhOS6uuWbvuWkjeS9nOWumuS7tuWkjeaWsOWIl+OBjOaWsOWIl+OBq+OBhOOBq+OBvuOBq+OCi+OCieWbvuWkjeOBjOOCjOOCieOCk+ODq+ODvOODrOODs+ODg+OCiwotLT4gPHBvbHlnb24gcG9pbnRzPSI1MCw1MCA1MCwxNTAgMTUwLDEwMCIgZmlsbD0idXJsKCNncmFkMSkiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS13aWR0aD0iMiIgLz48L3N2Zz4=">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=M+PLUS+Rounded+1c&display=swap" rel="stylesheet">
<title>文化発表会音声プレイヤー</title>
<style>
/* メインスタイル */
body {
font-family: "M PLUS Rounded 1c", 'Arial', sans-serif;
background-color: #0f0f33;
color: #e6f1ff;
margin: 0;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
}
h1 {
color: #a2c2e8;
text-align: center;
margin-bottom: 30px;
border-bottom: 1px solid #64ffda;
padding-bottom: 10px;
width: 100%;
}
.container {
display: flex;
flex-direction: column;
width: 95%;
max-width: 1000px;
background-color: rgba(17, 34, 64, 0.01);
border-radius: 10px;
padding: 20px;
box-shadow: 0 0 20px rgba(100, 255, 218, 0.2);
backdrop-filter: blur(2px);
border: 1px solid rgba(100, 255, 218, 0.1);
}
.audio-controls {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 15px;
}
.audio-item {
display: flex;
align-items: center;
gap: 10px;
}
.audio-item label {
min-width: 50px;
color: #9ab3d9;
}
.audio-slider {
flex-grow: 1;
height: 8px;
-webkit-appearance: none;
background: #1e2a47;
border-radius: 5px;
outline: none;
background-image: linear-gradient(#64ffda, #64ffda);
background-size: 100% 100%;
background-repeat: no-repeat;
}
.audio-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
background: #64ffda;
border-radius: 10%;
cursor: pointer;
}
.settings {
background-color: rbga(30, 42, 71, 0.3);
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.setting-item:last-child {
margin-bottom: 0;
}
.setting-item label {
color: #ccd6f6;
}
.global-volume-container,
.playback-speed-container {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.global-volume-slider,
.playback-speed-slider {
flex-grow: 1;
height: 8px;
-webkit-appearance: none;
background: #1e2a47;
border-radius: 5px;
outline: none;
background-image: linear-gradient(#64ffda, #64ffda);
background-size: 100% 100%;
background-repeat: no-repeat;
}
.global-volume-slider::-webkit-slider-thumb,
.playback-speed-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: #64ffda;
border-radius: 20%;
cursor: pointer;
}
.slider-value {
min-width: 40px;
text-align: right;
}
input[type="number"],
input[type="checkbox"],
select {
background-color: #112240;
border: 1px solid #64ffda;
color: #e6f1ff;
padding: 5px;
border-radius: 3px;
}
.tech-decoration {
width: 100%;
height: 2px;
background: linear-gradient(90deg, transparent, #64ffda, transparent);
margin: 20px 0;
}
/* ローディングアニメーション */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 9998;
transition: opacity 1s ease-out;
}
.spinner-box {
width: 300px;
height: 300px;
display: flex;
justify-content: center;
align-items: center;
background-color: transparent;
}
/* 軌道スタイル */
.leo {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
}
.blue-orbit {
width: 165px;
height: 165px;
border: 1px solid #91daffa5;
animation: spin3D 3s linear .2s infinite;
}
.green-orbit {
width: 120px;
height: 120px;
border: 1px solid #91ffbfa5;
animation: spin3D 2s linear 0s infinite;
}
.red-orbit {
width: 90px;
height: 90px;
border: 1px solid #ffca91a5;
animation: spin3D 1s linear 0s infinite;
}
.white-orbit {
width: 60px;
height: 60px;
border: 2px solid #ffffff;
animation: spin3D 10s linear 0s infinite;
}
.w1 {
transform: rotate3D(1, 1, 1, 90deg);
}
.w2 {
transform: rotate3D(1, 2, .5, 90deg);
}
.w3 {
transform: rotate3D(.5, 1, 2, 90deg);
}
/* キーフレームアニメーション */
@keyframes spin3D {
from {
transform: rotate3d(.5, .5, .5, 360deg);
}
to {
transform: rotate3d(0, 0, 0, 0deg);
}
}
.time-set-button {
background-color: #112240;
border: 1px solid #64ffda;
color: #e6f1ff;
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
margin-left: 5px;
transition: background-color 0.3s;
}
.time-set-button:hover {
background-color: rgba(100, 255, 218, 0.2);
}
/* 合成ボタンスタイル */
.combine-button {
background-color: #97c2f0;
color: #0a192f;
border: none;
padding: 10px 20px;
border-radius: 5px;
font-size: 16px;
cursor: pointer;
margin-top: 20px;
transition: all 0.3s;
font-weight: bold;
}
.combine-button:hover {
background-color: #52e0c4;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(100, 255, 218, 0.4);
}
.combine-button:disabled {
background-color: #3a5a78;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* 合成ステータスメッセージ */
.combine-status {
margin-top: 10px;
color: #97c2f0;
font-size: 14px;
height: 20px;
}
/* プレビューセクション */
.preview-section {
margin-top: 20px;
padding: 15px;
background-color: rgba(17, 34, 64, 0.7);
border-radius: 5px;
display: none;
}
.preview-section h3 {
margin-top: 0;
color: #97c2f0;
border-bottom: 1px solid #64ffda;
padding-bottom: 5px;
}
/* 無効状態のオーバーレイ */
.disabled-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(10, 25, 47, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
border-radius: 5px;
}
.disabled-message {
background-color: rgba(30, 42, 71, 0.9);
padding: 20px;
border-radius: 5px;
text-align: center;
max-width: 80%;
}
.disabled-message p {
margin-bottom: 15px;
}
.loader {
width: 80px;
aspect-ratio: 1;
border: 10px solid #000;
box-sizing: border-box;
background:
radial-gradient(farthest-side, #fff 98%, #0000) 50%/20px 20px,
radial-gradient(farthest-side, #fff 98%, #0000) 50%/20px 20px,
radial-gradient(farthest-side, #fff 98%, #0000) 50%/20px 20px,
radial-gradient(farthest-side, #fff 98%, #0000) 50%/20px 20px,
radial-gradient(farthest-side, #fff 98%, #0000) 50%/80% 80%,
#000;
background-repeat: no-repeat;
filter: blur(4px) contrast(10);
animation: squarePulse 1s infinite alternate;
}
@keyframes squarePulse {
0% {
background-position:
50% 50%, 50% 50%, 50% 50%, 50% 50%, 50% 50%, 50% 50%;
}
25% {
background-position:
50% 0, 50% 50%, 50% 50%, 50% 50%, 50% 50%, 50% 50%;
}
50% {
background-position:
50% 0, 50% 100%, 50% 50%, 50% 50%, 50% 50%, 50% 50%;
}
75% {
background-position:
50% 0, 50% 100%, 0 50%, 50% 50%, 50% 50%, 50% 50%;
}
100% {
background-position:
50% 0, 50% 100%, 0 50%, 100% 50%, 50% 50%, 50% 50%;
}
}
.sync-status {
position: absolute;
bottom: 100px;
left: 10px;
width: 150px;
height: 30px;
background-color: rgba(0, 0, 0, 0.7);
color: #97c2f0;
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
z-index: 5;
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
contain: strict;
}
.sync-status button {
background: none;
border: none;
color: #fff;
cursor: pointer;
font-size: 12px;
}
.time-markers-container {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 15px 0;
padding: 15px;
background-color: rgba(17, 34, 64, 0.8);
border-radius: 5px;
}
.time-marker {
font-size: 80%;
padding: 6px 8px;
background-color: rgba(100, 255, 218, 0.05);
border: 1px solid #64ffda;
border-radius: 4px;
cursor: grab;
user-select: none;
transition: background-color 0.3s;
}
.time-marker:hover {
background-color: rgba(100, 255, 218, 0.1);
}
.time-marker.dragging {
opacity: 0.7;
cursor: grabbing;
}
.time-input-container {
display: flex;
align-items: center;
gap: 5px;
}
.time-input-container input {
width: 80px;
}
/* プレイヤーコントロール */
.player-controls {
background-color: rgba(17, 34, 64, 0.2);
padding: 10px;
border-radius: 5px;
display: flex;
flex-direction: column;
gap: 10px;
transition: opacity 0.3s;
}
.progress-container {
width: 100%;
height: 10px;
background-color: #1e2a47;
border-radius: 5px;
cursor: pointer;
position: relative;
}
.progress-bar {
height: 100%;
background-color: #64ffda;
border-radius: 5px;
width: 0%;
position: relative;
}
.progress-time {
position: absolute;
top: -25px;
transform: translateX(-50%);
background-color: rgba(30, 42, 71, 0.9);
padding: 3px 6px;
border-radius: 3px;
font-size: 12px;
display: none;
white-space: nowrap;
}
.progress-marker {
position: absolute;
bottom: 0px;
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 10px solid #ff5555;
transform: translateX(-50%);
z-index: 2;
}
.main-controls {
display: flex;
align-items: center;
gap: 15px;
}
.control-button {
background: none;
border: none;
color: #e6f1ff;
font-size: 18px;
cursor: pointer;
padding: 5px;
border-radius: 30%;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.3s;
}
.control-button:hover {
background-color: rgba(100, 255, 218, 0.2);
}
.time-display {
font-size: 14px;
color: #ccd6f6;
white-space: nowrap;
}
.volume-control {
display: flex;
align-items: center;
gap: 5px;
margin-left: auto;
}
.volume-button {
background: none;
border: none;
color: #e6f1ff;
font-size: 18px;
cursor: pointer;
padding: 5px;
}
.volume-slider {
width: 80px;
height: 6px;
-webkit-appearance: none;
background: #1e2a47;
border-radius: 3px;
outline: none;
opacity: 0;
transition: opacity 0.3s, width 0.3s;
background-image: linear-gradient(#6aebfc, #6aebfc);
background-size: 100% 100%;
background-repeat: no-repeat;
}
.volume-control:hover .volume-slider {
opacity: 1;
width: 100px;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
background: #6aebfc;
border-radius: 30%;
cursor: pointer;
}
.speed-control {
display: flex;
align-items: center;
gap: 5px;
}
.speed-slider {
width: 120px;
height: 6px;
-webkit-appearance: none;
background: #1e2a47;
border-radius: 3px;
outline: none;
background-image: linear-gradient(#64d1ff, #64d1ff);
background-size: 100% 100%;
background-repeat: no-repeat;
}
.speed-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
background: #5bb7de;
border-radius: 20%;
cursor: pointer;
}
.speed-value {
font-size: 14px;
min-width: 30px;
text-align: center;
}
</style>
</head>
<body>
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-NDL2LKLQ" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->
<!-- ローディングオーバーレイ -->
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner-box">
<div class="blue-orbit leo">
</div>
<div class="green-orbit leo">
</div>
<div class="red-orbit leo">
</div>
<div class="white-orbit w1 leo">
</div>
<div class="white-orbit w2 leo">
</div>
<div class="white-orbit w3 leo">
</div>
</div>
</div>
<h1 id="title-name">文化発表会音声プレイヤー</h1>
<div class="details-container">
<details id="usageDetails">
<summary style="font-size: 20px">使い方</summary>
<h3>音声の合成</h3>
<p><b>一番最初に「音声コントロール」で、スライダーを使用して各パートの音量を調整してください。</b>「合成音量係数」は全体の音量を上げます。<br>
自分のパートの音量を1、それ以外のパートの音量を0.3くらいにすると他のパートのタイミングを確認しながら練習できます。<br>
<b>調整が終わると「音声を合成」を押してください。</b></p>
<hr>
<h3>プレイヤーの使い方</h3>
<p>「▶」や「⏸」ボタンで再生や一時停止ができます。「↺」で最初から再生できます。音量スライダーで音量を変更できます。スライダーで再生速度も変更できます。</p>
<hr>
<h3>高度な繰り返し機能</h3>
<p>特定の秒数の間を再生したい場合は、再生を開始する秒数を「再生開始秒数」、再生を終了する秒数を「再生終了秒数」を設定してください。ドラッグアンドドロップで楽譜に載っている区間を設定できます。<br>
ループがオンの場合は再生終了秒数になると再生開始秒数に戻ります。<br>
設定ができたら「時間設定を適用」ボタンを押してください。</p>
<hr>
<h3>高度な設定</h3>
<ul>
<li>全体音量係数:全体の音量を上げたり下げたりできます。10が最大です。</li>
<li>再生速度:再生速度を変更できます。</li>
<li>テンポ:テンポを入力すると、そのテンポになるように再生速度を自動調整します。</li>
</ul>
<hr>
<h3>オフライン設定</h3>
<p>ページをオフラインで使用できるようにします。ただし、保存されるキャッシュが古い場合、更新プログラムがなかったため、正常に動作しない可能性があります。現在はこのバグは更新ポップアップを表示することで修正されました。</p>
</details>
</div>
<script>
// 初回は開く、それ以降は閉じる
document.addEventListener("DOMContentLoaded", () => {
const details = document.getElementById("usageDetails");
const openedBefore = localStorage.getItem("usageDetailsOpened");
if(!openedBefore) {
details.setAttribute("open", "true");
localStorage.setItem("usageDetailsOpened", "true");
} else {
details.removeAttribute("open");
}
});
</script>
<div class="audio-controls">
<details>
<summary style="font-size: 1.2em;">オフライン設定</summary>
<div class="swsettings" id="swsettings">
<a>下のボタンを押すと、サービスワーカーでページをオフラインで使用できるようにします。<br>
ブラウザの仕様により、オフラインで開く際に<b>最初のみ二回再読み込みボタンを押さなければいけません。</b><br>
不安定な機能で、ページの更新などができなくなるバグが発生する可能性があります。</a>
<br>
<button class="combine-button" id="sw-register-btn" disabled>登録を開始</button>
<div class="combine-status" id="sw-status">
</div>
</div>
</details><br>
<h2>音声コントロール</h2>
<span>各パートの音の大きさを設定できます。自分のパートの音量を1、それ以外のパートの音量を0.3くらいにすると他のパートのタイミングを確認しながら練習できます。<br>
※「ピアノ」と「全体」は他のパートと音がずれるため、組み合わせて使用しないでください。</span>
<div class="audio-item">
<label>ソプラノ</label>
<input type="range" class="audio-slider" data-audio="s" min="0" max="1" step="0.01" value="1">
<span class="slider-value volume-value">1.00</span>
</div>
<div class="audio-item">
<label>アルト </label>
<input type="range" class="audio-slider" data-audio="a" min="0" max="1" step="0.01" value="1">
<span class="slider-value volume-value">1.00</span>
</div>
<div class="audio-item">
<label>テノール</label>
<input type="range" class="audio-slider" data-audio="t" min="0" max="1" step="0.01" value="1">
<span class="slider-value volume-value">1.00</span>
</div>
<div class="audio-item">
<label>ピアノ </label>
<input type="range" class="audio-slider" data-audio="p" min="0" max="1" step="0.01" value="1">
<span class="slider-value volume-value">1.00</span>
</div>
<div class="audio-item">
<label>全体(非推奨)</label>
<input type="range" class="audio-slider" data-audio="k" min="0" max="1" step="0.01" value="0">
<span class="slider-value volume-value">0.00</span>
</div>
<!-- FFmpeg用の音量設定を追加 -->
<h3>FFmpeg合成用音量設定</h3>
<span>FFmpegで音声を合成する際の音量設定です。通常は1.0で問題ありません。</span>
<div class="audio-item">
<label>合成音量係数:</label>
<input type="range" class="audio-slider" id="ffmpeg-volume" min="0" max="20" step="0.01" value="1">
<span class="slider-value" id="ffmpeg-volume-value">1.00</span>
</div>
<!-- 合成ボタンとステータス -->
<button class="combine-button" id="combine-button">音声を合成</button>
<div class="combine-status" id="combine-status">
</div>
</div>
<div class="container">
<div class="tech-decoration">
</div>
<div class="sync-status" id="sync-status" style="display: none;">
<span id="sync-status-text">同期状態</span>
<button id="sync-status-close" aria-label="閉じる">×</button>
</div>
<!-- プレビューセクション -->
<div class="preview-section" id="preview-section" hidden>
<h3 hidden>プレビュー</h3>
<p hidden>合成された音声をプレビューできます。再生ボタンをクリックして確認してください。</p>
<button class="control-button" id="preview-button" hidden></button>
<span id="preview-time" hidden>00:00 / 00:00</span>
</div>
<!-- 音声プレイヤーコントロール -->
<div class="player-controls" id="player-controls">
<div class="disabled-overlay" id="disabledOverlay" style="display: block;">
<div class="disabled-message">
<p>「合成」ボタンで音声を合成してください。</p>
</div>
</div>
<div class="progress-container" id="progress-container">
<div class="progress-bar" id="progress-bar">
</div>
<div class="progress-time" id="progress-time">00:00</div>
<div class="progress-marker" id="start-marker" style="left: 0%; display: none;">
</div>
<div class="progress-marker" id="end-marker" style="left: 100%; display: none;">
</div>
</div>
<div class="main-controls">
<button class="control-button" id="play-pause-btn" disabled></button>
<button class="control-button" id="reset-btn" disabled title="最初から再生"></button>
<div class="time-display" id="time-display">00:00.00 / 00:00.00</div>
<div class="volume-control">
<button class="volume-button" id="volume-btn" disabled>🔊</button>
<input type="range" class="volume-slider" id="volume-slider" min="0" max="1" step="0.01" value="1" disabled>
</div>
<div class="speed-control">
<span class="speed-value" id="speed-value">1.00x</span>
<input type="range" class="speed-slider" id="speed-slider" min="0.01" max="5" step="0.01" value="1" disabled>
</div>
</div>
</div>
<div class="tech-decoration">
</div>
<div class="settings">
<div class="time-markers-container" id="time-markers-container">
<span>このボックスをドラッグして「再生開始秒数」や「再生終了秒数」の入力ボックスの上で離すと秒数を簡単に変更できます。各秒数は楽譜に合わせています。<br>
時の旅人はAからE、地球星歌は①から⑤で区切っていて、地球星歌はリピート記号があるので①と②は1番と2番があります。</span>
<!-- ここにタイムマーカーが動的に追加されます -->
</div>
<div class="setting-item">
<label for="start-time">再生開始秒数:</label>
<div class="time-input-container">
<input type="number" id="start-time" min="0" value="0" step="0.01" disabled>
<button class="time-set-button" id="set-start-time" disabled>現在の秒数に設定</button>
</div>
</div>
<div class="setting-item">
<label for="end-time">再生終了秒数:</label>
<div class="time-input-container">
<input type="number" id="end-time" min="0" value="0" step="0.01" disabled>
<button class="time-set-button" id="set-end-time" disabled>現在の秒数に設定</button>
<button class="time-set-button" id="reset-end-time" disabled>音声の長さに戻す</button>
</div>
</div>
<div class="setting-item">
<label for="loop">ループ再生:</label>
<input type="checkbox" id="loop" disabled>
</div>
<div class="setting-item">
<button class="combine-button" id="apply-time-btn" disabled>時間設定を適用</button>
</div>
<h2>設定</h2>
<div class="setting-item">
<div class="global-volume-container">
<label>再生音量:</label>
<input type="range" class="global-volume-slider" id="global-volume" min="0" max="1" step="0.01" value="0.5" disabled>
<span class="slider-value" id="global-volume-value">0.50</span>
</div>
</div>
<div class="setting-item">
<div class="playback-speed-container">
<label>再生速度:</label>
<input type="range" class="playback-speed-slider" id="playback-speed" min="0.01" max="5" step="0.001" value="1" disabled>
<span class="slider-value" id="playback-speed-value">1.00x</span>
</div>
</div>
<div class="setting-item">
<label for="tempo">テンポ (BPM):</label>
<input type="number" id="tempo" min="40" max="200" value="92" step="0.1">
<span id="tempo-speed-value">1.00x</span>
</div>
<span>↑練習したいテンポを入力するとそのテンポに合わせて再生速度スライダーを変更できます。
<br>楽譜に記載された「♪=」の横のテンポ(時の旅人は約92、地球星歌は約66)を基準に計算します。</span>
</div>
</div>
<script>
window.addEventListener('load', async () => {
const statusElem = document.getElementById('sw-status');
const registerBtn = document.getElementById('sw-register-btn');
const UPDATE_URL = '/update.txt';
const LS_SW_VERSION = 'sw_version';
const LS_LAST_SEEN = 'last_seen_update_txt';
const CHECK_INTERVAL_MS = 30000;
const SKIP_WAITING_TIMEOUT_MS = 8000;
const log = (v) => { console.log(v); try { if(statusElem) statusElem.textContent = String(v); } catch (e) {} };
const err = (m, e) => { console.error(m, e); try { if(statusElem) statusElem.textContent = m + (e?.message ? (' — ' + e.message) : ''); } catch (_) {} };
if(!('serviceWorker' in navigator)) {
err('このブラウザは Service Worker に対応していません。');
if(registerBtn) registerBtn.disabled = true;
return;
}
if(registerBtn) registerBtn.disabled = false;
// CSS
if(!document.getElementById('sw-update-style')) {
const style = document.createElement('style');
style.id = 'sw-update-style';
style.textContent = `.sw-popup{position:fixed;top:20px;right:20px;background:#fff3cd;color:#856304;border:1px solid #ffeeba;border-radius:8px;padding:16px;z-index:10000;width:320px;font-family:sans-serif}
.sw-popup .close{position:absolute;top:6px;right:8px;background:none;border:none;font-weight:bold;cursor:pointer}
.sw-popup .primary{display:block;margin-top:12px;padding:8px;border:none;background:#ffc107;border-radius:4px;width:100%;cursor:pointer}
.sw-popup .secondary{display:block;margin-top:8px;padding:6px;border:1px solid #ddd;background:#fff;border-radius:4px;width:100%;cursor:pointer}`;
document.head.appendChild(style);
}
// popup queue
const queue = [];
let showing = false;
function enqueue(type, payload = {}) { queue.push({ type, payload });
showNext(); }
function showNext() { if(showing || !queue.length) return; const it = queue.shift();
showing = true;
show(it.type, it.payload).finally(() => { showing = false;
setTimeout(showNext, 200); }); }
function show(type, payload) {
return new Promise(resolve => {
const pop = document.createElement('div');
pop.className = 'sw-popup';
pop.setAttribute('data-type', type);
let title = '更新',
body = '更新があります。';
if(type === 'reload') { title = 'ページ更新が必要です';
body = 'ページが新しくなりました。再読み込みしてください。'; } else if(type === 'sw-update') { title = 'Service Worker の更新';
body = '新しい Service Worker が利用可能です。反映するには更新してください。'; }
pop.innerHTML = `<button class="close" aria-label="閉じる">×</button><strong>${title}</strong><p style="margin:10px 0 0">${body}</p><div><button class="primary">${payload.primaryText|| (type==='sw-update'?'サービスワーカーを更新する':'再読み込み')}</button><button class="secondary">${payload.secondaryText||'後で'}</button></div>`;
document.body.appendChild(pop);
pop.querySelector('.close').addEventListener('click', () => { pop.remove();
resolve(); });
pop.querySelector('.secondary').addEventListener('click', () => { pop.remove();
resolve(); });
const primary = pop.querySelector('.primary');
if(type === 'reload') {
primary.addEventListener('click', () => location.reload());
} else if(type === 'sw-update') {
primary.addEventListener('click', async () => {
primary.disabled = true;
try {
log('Service Worker 更新処理を開始します...');
const reg = await navigator.serviceWorker.getRegistration();
// まず、サーバに問い合わせて sw.js を更新取得してもらう(reg.update)
if(reg) {
try { await reg.update();
log('reg.update() を呼び出しました。'); } catch (e) { console.warn('reg.update() エラー', e); }
} else {
log('registration が見つかりません。');
}
// waiting または installing を監視して installed になったら skipWaiting を送る
let handled = false;
const attemptHandle = async () => {
const r = await navigator.serviceWorker.getRegistration();
if(!r) return;
if(r.waiting) {
log('waiting を発見: skipWaiting を送信します。');
r.waiting.postMessage({ type: 'SKIP_WAITING' });
handled = true;
// controllerchange を待つ
navigator.serviceWorker.addEventListener('controllerchange', function oncc() {
navigator.serviceWorker.removeEventListener('controllerchange', oncc);
(async () => {
// activated されてから最新の update.txt を保存
const latest = await fetchVersion();
if(latest) {
localStorage.setItem(LS_SW_VERSION, latest);
log('sw_version を保存しました: ' + latest);
}
setTimeout(() => location.reload(), 400);
})();
});
return;
}
if(r.installing) {
log('installing を監視します...');
r.installing.addEventListener('statechange', function onsc() {
if(r.installing.state === 'installed') {
r.installing.removeEventListener('statechange', onsc);
if(r.waiting) {
r.waiting.postMessage({ type: 'SKIP_WAITING' });
}
}
});
return;
}
};
await attemptHandle();
// タイムアウトしてもフォールバック
setTimeout(async () => {
if(!handled) {
log('waiting の応答がありません。フォールバックで update.txt を保存してリロードします。');
const latest = await fetchVersion();
if(latest) {
localStorage.setItem(LS_SW_VERSION, latest);
log('sw_version を保存しました(フォールバック): ' + latest);
}
location.reload();
}
}, SKIP_WAITING_TIMEOUT_MS);
} catch (e) {
err('sw-update 処理でエラー', e);
const latest = await fetchVersion();
if(latest) localStorage.setItem(LS_SW_VERSION, latest);
location.reload();
}
});
} else {
primary.addEventListener('click', () => { pop.remove();
resolve(); });
}
});
}
// fetch update.txt
async function fetchVersion() {
try {
const r = await fetch(UPDATE_URL + '?t=' + Date.now(), { cache: 'no-store' });
if(r.ok) return (await r.text()).trim();
} catch (e) { console.warn('update.txt fetch failed', e); }
return null;
}
// メインロジック
let lastSeen = localStorage.getItem(LS_LAST_SEEN) || null;
async function check() {
const latest = await fetchVersion();
if(!latest) return;
// ページの「最後に見たupdate.txt」と違えば reload を提示
if(lastSeen && latest !== lastSeen) {
enqueue('reload');
}
lastSeen = latest;
localStorage.setItem(LS_LAST_SEEN, latest);
// sw_version と最新が違えば sw-update を提示(controller に依存しない)
const swv = localStorage.getItem(LS_SW_VERSION);
if(swv && swv !== latest) {
enqueue('sw-update');
}
}
// 初回
(async () => {
const latest = await fetchVersion();
if(latest) {
if(!lastSeen) {
lastSeen = latest;
localStorage.setItem(LS_LAST_SEEN, latest);
}
const swv = localStorage.getItem(LS_SW_VERSION);
if(swv && swv !== latest) enqueue('sw-update');
}
setInterval(check, CHECK_INTERVAL_MS);
})();
// register ボタンを押したら sw を登録して sw_version を保存
if(registerBtn) {
registerBtn.addEventListener('click', async () => {
registerBtn.disabled = true;
try {
log('既存の Service Worker を unregister...');
const regs = await navigator.serviceWorker.getRegistrations();
for(const r of regs) await r.unregister();
log('registering...');
await navigator.serviceWorker.register('/sw.js');
const newv = await fetchVersion();
if(newv) {
localStorage.setItem(LS_SW_VERSION, newv);
log('sw_version を保存: ' + newv);
} else {
log('update.txt を取得できませんでした(sw_version 保存不可)。');
}
registerBtn.textContent = 'ページをリロード';
registerBtn.disabled = false;
registerBtn.onclick = () => location.reload();
} catch (e) {
err('登録失敗', e);
registerBtn.disabled = false;
}
});
}
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 同期管理用の変数
let lastSyncTime = 0;
let isBuffering = false;
let syncDriftLog = [];
let syncCheckInterval;
let audioContext;
let isCheckingSync = false;
let isInBackgroundTab = false;
try {
audioContext = new(window.AudioContext || window.webkitAudioContext)();
} catch (e) {
console.error('Web Audio APIがサポートされていません:', e);
}
const urlParams = new URLSearchParams(window.location.search);
const isTMode = urlParams.has('mode') && urlParams.get('mode') === 't';
const basePath = isTMode ? '/t/' : '/';
// ローディング状態を管理
let loadingCount = 0;
let totalToLoad = 5; // 5つの音声ファイル
let lastUpdateTime = 0;
const updateInterval = 100;
function checkLoadingComplete() {
loadingCount++;
if (loadingCount >= totalToLoad) {
setTimeout(function() {
const loadingOverlay = document.getElementById('loadingOverlay');
if (loadingOverlay) {
loadingOverlay.style.opacity = '0';
setTimeout(function() {
loadingOverlay.style.display = 'none';
}, 1000);
}
}, 500);
}
}
function handleError(error, message) {
console.error(message, error);
window.alert(`${message}\n\nエラー詳細: ${error.message}`);
}
// 要素を取得(安全に)
const playPauseBtn = document.getElementById('play-pause-btn');
const timeDisplay = document.getElementById('time-display');
const progressContainer = document.getElementById('progress-container');
const progressBar = document.getElementById('progress-bar');
const progressTime = document.getElementById('progress-time');
const volumeBtn = document.getElementById('volume-btn');
const volumeSlider = document.getElementById('volume-slider');
const speedSlider = document.getElementById('speed-slider');
const speedValue = document.getElementById('speed-value');
const playbackSpeedSlider = document.getElementById('playback-speed');
const playbackSpeedValue = document.getElementById('playback-speed-value');
const startTimeInput = document.getElementById('start-time');
const endTimeInput = document.getElementById('end-time');
const loopCheckbox = document.getElementById('loop');
const globalVolumeSlider = document.getElementById('global-volume');
const globalVolumeValue = document.getElementById('global-volume-value');
const audioSliders = document.querySelectorAll('.audio-slider');
const volumeValues = document.querySelectorAll('.volume-value');
const setStartTimeBtn = document.getElementById('set-start-time');
const setEndTimeBtn = document.getElementById('set-end-time');
const resetEndTimeBtn = document.getElementById('reset-end-time');
const combineButton = document.getElementById('combine-button');
const combineStatus = document.getElementById('combine-status');
const previewSection = document.getElementById('preview-section');
const previewButton = document.getElementById('preview-button');
const previewTime = document.getElementById('preview-time');
const syncStatus = document.getElementById('sync-status');
const syncStatusText = document.getElementById('sync-status-text');
const syncStatusClose = document.getElementById('sync-status-close');
const startMarker = document.getElementById('start-marker');
const endMarker = document.getElementById('end-marker');
const tempoInput = document.getElementById('tempo');
const tempoSpeedValue = document.getElementById('tempo-speed-value');
const applyTimeBtn = document.getElementById('apply-time-btn');
const resetBtn = document.getElementById('reset-btn');
// FFmpeg用の音量スライダーを追加
const ffmpegVolumeSlider = document.getElementById('ffmpeg-volume');
const ffmpegVolumeValue = document.getElementById('ffmpeg-volume-value');
// 音声オブジェクトを作成
const audioElements = {};
const audioBuffers = {};
const audioFiles = ['p', 'a', 't', 's', 'k'];
let combinedAudioElement = null;
let isAudioCombined = false;
let currentVolumes = {
p: 0,
a: 1,
t: 1,
s: 1,
k: 0
};
// 初期化
let audioDuration = 0;
let isPlaying = false;
let lastVolume = 1;
let currentPlaybackRate = 1;
// リセットボタンクリック処理
if (resetBtn) {
resetBtn.addEventListener('click', () => {
const startTime = parseFloat(startTimeInput.value) || 0;
seekMedia(startTime);
if (isPlaying) {
playMedia();
}
});
}
function startSyncCheck() {
if (isCheckingSync) return;
isCheckingSync = true;
if (syncCheckInterval) clearInterval(syncCheckInterval);
syncCheckInterval = setInterval(checkSync, 1000);
}
function stopSyncCheck() {
isCheckingSync = false;
if (syncCheckInterval) clearInterval(syncCheckInterval);
}
function checkSync() {
if (!isAudioCombined || !isPlaying || isBuffering || isInBackgroundTab) return;
const audioTime = combinedAudioElement.currentTime;
const drift = audioTime - audioTime; // 音声のみなので常に0
syncDriftLog.push(drift);
if (syncDriftLog.length > 5) syncDriftLog.shift();
const avgDrift = syncDriftLog.reduce((a, b) => a + b, 0) / syncDriftLog.length;
if (syncStatusText) {
syncStatusText.textContent = `同期ズレ: ${avgDrift.toFixed(3)}秒`;
}
}
if (applyTimeBtn) {
applyTimeBtn.addEventListener('click', function() {
// 現在再生中なら一時停止
const wasPlaying = isPlaying;
if (isPlaying) {
pauseMedia();
}
// 開始時間と終了時間を取得
const startTime = parseFloat(startTimeInput.value) || 0;
const endTime = parseFloat(endTimeInput.value) || audioDuration;
// 現在位置が開始時間より前なら開始時間に移動
if (combinedAudioElement.currentTime < startTime) {
combinedAudioElement.currentTime = startTime;
}
// 現在位置が終了時間より後なら開始時間に移動
else if (combinedAudioElement.currentTime > endTime) {
combinedAudioElement.currentTime = startTime;
}
// 再生中だった場合は再開
if (wasPlaying) {
playMedia();
}
// マーカーを更新
updateProgressMarkers();
});
}
// 音声ファイルをロード
function loadAudioFiles() {
audioFiles.forEach(file => {
try {
const audio = new Audio(`${basePath}${file}.mp3`);
audio.preload = 'auto';
audio.loop = false;
audioElements[file] = audio;
audio.addEventListener('loadedmetadata', function() {
console.log(`${basePath}${file}.mp3 loaded`);
checkLoadingComplete();
});
audio.addEventListener('error', function() {
console.error(`音声ファイル読み込みエラー (${basePath}${file}.mp3):`, audio.error);
checkLoadingComplete();
});
} catch (error) {
console.error(`音声ファイル初期化エラー (${basePath}${file}.mp3):`, error);
checkLoadingComplete();
}
});
}
// 音声を結合する関数
async function combineAudio() {
if (combinedAudioElement) {
combinedAudioElement.pause();
// 古い src を一旦保持
const oldSrc = combinedAudioElement.src;
combinedAudioElement.src = '';
// 古い src が blob URL なら revoke
if (oldSrc.startsWith('blob:')) {
URL.revokeObjectURL(oldSrc);
}
}
combineButton.disabled = true;
combineStatus.textContent = "音声を合成中...";
try {
// 現在の音量設定を保存
audioFiles.forEach(file => {
currentVolumes[file] = parseFloat(document.querySelector(`.audio-slider[data-audio="${file}"]`).value);
});
// FFmpeg用の音量係数を取得 (0-2)
const ffmpegVolume = parseFloat(ffmpegVolumeSlider.value);
// 各音声ファイルをデコード
const audioBufferPromises = audioFiles.map(async file => {
const audio = audioElements[file];
if (!audio) return null;
// 音量が0の場合はスキップ
if (currentVolumes[file] === 0) return null;
const response = await fetch(`${basePath}${file}.mp3`);
const arrayBuffer = await response.arrayBuffer();
return await audioContext.decodeAudioData(arrayBuffer);
});
// すべての音声バッファを取得
const buffers = await Promise.all(audioBufferPromises);
audioFiles.forEach((file, index) => {
audioBuffers[file] = buffers[index];
});
// 最長の音声バッファの長さを取得
const maxDuration = Math.max(...buffers.filter(b => b).map(b => b.duration));
// 新しい音声バッファを作成
const combinedAudioBuffer = audioContext.createBuffer(
2, // ステレオ
audioContext.sampleRate * maxDuration,
audioContext.sampleRate
);
// 各音声バッファを結合(FFmpeg音量を適用)
for (let file of audioFiles) {
if (!audioBuffers[file] || currentVolumes[file] === 0) continue;
const buffer = audioBuffers[file];
const volume = currentVolumes[file] * ffmpegVolume; // FFmpeg音量を乗算
// 各チャンネルに音声を加算
for (let channel = 0; channel < 2; channel++) {
const inputData = buffer.getChannelData(channel % buffer.numberOfChannels);
const outputData = combinedAudioBuffer.getChannelData(channel);
for (let i = 0; i < inputData.length; i++) {
outputData[i] += inputData[i] * volume;
}
}
}
// AudioBufferをBlobに変換
const blob = bufferToWave(combinedAudioBuffer);
const url = URL.createObjectURL(blob);
// 新しいaudio要素を作成
combinedAudioElement = new Audio(url);
combinedAudioElement.preservesPitch = true;
combinedAudioElement.mozPreservesPitch = true;
combinedAudioElement.webkitPreservesPitch = true;
combinedAudioElement.playbackRate = currentPlaybackRate;
// 音声のメタデータが読み込まれたら
combinedAudioElement.addEventListener('loadedmetadata', function() {
try {
audioDuration = combinedAudioElement.duration;
if (endTimeInput) {
endTimeInput.value = audioDuration.toFixed(2);
endTimeInput.max = audioDuration;
}
if (startTimeInput) {
startTimeInput.max = audioDuration - 0.1;
}
updateTimeDisplay();
} catch (error) {
handleError(error, '音声メタデータ読み込み中にエラーが発生しました');
}
});
// 音声エラー処理
combinedAudioElement.addEventListener('error', function() {
handleError(combinedAudioElement.error, '音声読み込み中にエラーが発生しました');
});
// 時間更新時の処理
combinedAudioElement.addEventListener('timeupdate', function() {
updateTimeDisplay();
checkEndTime();
});
// 音声終了時の処理
combinedAudioElement.addEventListener('ended', function() {
handleAudioEnd();
});
isAudioCombined = true;
combineStatus.textContent = "音声の合成が完了しました";
enablePlayerControls();
combineButton.disabled = false;
document.addEventListener('visibilitychange', async () => {
if (document.hidden) {
isInBackgroundTab = true;
} else {
isInBackgroundTab = false;
}
});
// 合成後に音量と再生速度を適用
applyVolume();
applyPlaybackRate();
} catch (error) {
console.error('音声合成エラー:', error);
combineStatus.textContent = "音声の合成に失敗しました";
combineButton.disabled = false;
}
}
function checkEndTime() {
if (!combinedAudioElement || !isAudioCombined) return;
const currentTime = combinedAudioElement.currentTime;
const endTime = parseFloat(endTimeInput.value) || combinedAudioElement.duration;
// 終了時間を超えた場合の処理
if (currentTime >= endTime) {
if (loopCheckbox && loopCheckbox.checked) {
// ループ再生の場合、開始時間に戻す
const startTime = parseFloat(startTimeInput.value) || 0;
seekMedia(startTime);
if (isPlaying) {
playMedia();
}
} else {
// ループしない場合、停止する
pauseMedia();
// 終了時間で正確に止める
combinedAudioElement.currentTime = endTime;
}
}
}
function handleAudioEnd() {
if (loopCheckbox && loopCheckbox.checked) {
const startTime = parseFloat(startTimeInput.value) || 0;
const endTime = parseFloat(endTimeInput.value) || combinedAudioElement.duration;
// 開始時間に戻して再生
seekMedia(startTime);
if (isPlaying) {
playMedia();
}
} else {
pauseMedia();
// 音声の最後まで再生された場合、終了時間に設定
const endTime = parseFloat(endTimeInput.value) || combinedAudioElement.duration;
combinedAudioElement.currentTime = endTime;
}
}
function bufferToWave(abuffer) {
const numOfChan = abuffer.numberOfChannels,
length = abuffer.length * numOfChan * 2 + 44,
buffer = new ArrayBuffer(length),
view = new DataView(buffer),
channels = [],
sampleRate = abuffer.sampleRate;
// posをletで宣言(constから変更)
let pos = 0;
// write WAV header
setUint32(0x46464952); // "RIFF"
setUint32(length - 8); // file length - 8
setUint32(0x45564157); // "WAVE"
setUint32(0x20746d66); // "fmt " chunk
setUint32(16); // length = 16
setUint16(1); // PCM (uncompressed)
setUint16(numOfChan);
setUint32(sampleRate);
setUint32(sampleRate * 2 * numOfChan);
setUint16(numOfChan * 2);
setUint16(16);
setUint32(0x61746164); // "data" - chunk
setUint32(length - pos - 4);
// write interleaved data
for (let i = 0; i < abuffer.length; i++) {
for (let channel = 0; channel < numOfChan; channel++) {
let sample = abuffer.getChannelData(channel)[i] * 0x7fff;
if (sample < -32768) sample = -32768;
if (sample > 32767) sample = 32767;
view.setInt16(pos, sample, true);
pos += 2;
}
}
function setUint16(data) {
view.setUint16(pos, data, true);
pos += 2;
}
function setUint32(data) {
view.setUint32(pos, data, true);
pos += 4;
}
return new Blob([buffer], {
type: 'audio/wav'
});
}
function applyVolume() {
if (!isAudioCombined || !combinedAudioElement) return;
// ベース音量 (0-1)
const baseVolume = parseFloat(volumeSlider.value);
// グローバル音量係数 (0-1)
const globalVolume = parseFloat(globalVolumeSlider.value);
// 最終音量 (0-1)
const finalVolume = Math.max(0, Math.min(1, baseVolume * globalVolume));
// 音声の音量を設定
combinedAudioElement.volume = finalVolume;
// 音量アイコンを更新
updateVolumeIcon();
}
function applyPlaybackRate() {
if (!isAudioCombined || !combinedAudioElement) return;
const speed = parseFloat(playbackSpeedSlider.value);
currentPlaybackRate = speed;
combinedAudioElement.playbackRate = speed;
if (speedValue) speedValue.textContent = speed.toFixed(2) + 'x';
if (playbackSpeedValue) playbackSpeedValue.textContent = speed.toFixed(2) + 'x';
if (speedSlider) speedSlider.value = speed;
}
// プレイヤーコントロールを有効化
function enablePlayerControls() {
// disabledOverlayが存在する場合のみ操作
const disabledOverlay = document.getElementById('disabledOverlay');
if (disabledOverlay) {
disabledOverlay.style.display = 'none';
}
if (playPauseBtn) playPauseBtn.disabled = false;
if (volumeBtn) volumeBtn.disabled = false;
if (volumeSlider) volumeSlider.disabled = false;
if (speedSlider) speedSlider.disabled = false;
if (startTimeInput) startTimeInput.disabled = false;
if (endTimeInput) endTimeInput.disabled = false;
if (resetEndTimeBtn) resetEndTimeBtn.disabled = false;
if (loopCheckbox) loopCheckbox.disabled = false;
if (globalVolumeSlider) globalVolumeSlider.disabled = false;
if (setStartTimeBtn) setStartTimeBtn.disabled = false;
if (setEndTimeBtn) setEndTimeBtn.disabled = false;
if (playbackSpeedSlider) playbackSpeedSlider.disabled = false;
if (applyTimeBtn) applyTimeBtn.disabled = false;
if (resetBtn) resetBtn.disabled = false;
}
// プレビュー再生
function togglePreview() {
if (!isAudioCombined || !combinedAudioElement || !previewButton) return;
if (previewButton.textContent === '▶') {
// 再生
combinedAudioElement.currentTime = 0;
combinedAudioElement.play()
.then(() => {
previewButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M520-200v-560h240v560H520Zm-320 0v-560h240v560H200Zm400-80h80v-400h-80v400Zm-320 0h80v-400h-80v400Zm0-400v400-400Zm320 0v400-400Z"/></svg>';
// プレビューの時間表示を更新
const updatePreviewTime = () => {
if (!combinedAudioElement || !isAudioCombined || !previewTime) return;
const currentTime = combinedAudioElement.currentTime;
const duration = combinedAudioElement.duration;
if (currentTime >= duration) {
previewButton.textContent = '▶';
previewTime.textContent = `00:00 / ${formatTime(duration)}`;
return;
}
previewTime.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`;
requestAnimationFrame(updatePreviewTime);
};
updatePreviewTime();
})
.catch(e => console.error('プレビュー再生エラー:', e));
} else {
// 一時停止
combinedAudioElement.pause();
previewButton.textContent = '▶';
}
}
// 時間をフォーマットするヘルパー関数
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
// 時間表示を更新
function updateTimeDisplay() {
const now = performance.now();
if (now - lastUpdateTime < updateInterval) return;
lastUpdateTime = now;
try {
if (!combinedAudioElement || !timeDisplay) return;
const currentTime = combinedAudioElement.currentTime;
const duration = combinedAudioElement.duration || audioDuration;
const currentMinutes = Math.floor(currentTime / 60);
const currentSeconds = Math.floor(currentTime % 60);
const currentMilliseconds = Math.floor((currentTime % 1) * 100);
const durationMinutes = Math.floor(duration / 60);
const durationSeconds = Math.floor(duration % 60);
const durationMilliseconds = Math.floor((duration % 1) * 100);
timeDisplay.textContent =
`${String(currentMinutes).padStart(2, '0')}:${String(currentSeconds).padStart(2, '0')}.${String(currentMilliseconds).padStart(2, '0')} / ` +
`${String(durationMinutes).padStart(2, '0')}:${String(durationSeconds).padStart(2, '0')}.${String(durationMilliseconds).padStart(2, '0')}`;
const progressPercent = (currentTime / duration) * 100;
if (progressBar) {
progressBar.style.width = `${progressPercent}%`;
}
} catch (error) {
console.error('時間表示更新エラー:', error);
}
}
// 再生/一時停止をトグル
function togglePlayPause() {
if (isPlaying) {
pauseMedia();
} else {
playMedia();
}
}
function playMedia() {
try {
if (!combinedAudioElement) return;
const duration = combinedAudioElement.duration || audioDuration;
const startTime = parseFloat(startTimeInput.value) || 0;
const endTime = parseFloat(endTimeInput.value) || duration;
// 現在位置が終了時間を超えている場合は開始時間に戻す
if (combinedAudioElement.currentTime >= endTime) {
combinedAudioElement.currentTime = startTime;
}
const playPromise = combinedAudioElement.play();
if (playPromise !== undefined) {
playPromise.then(() => {
isPlaying = true;
if (playPauseBtn) playPauseBtn.textContent = '⏸';
startSyncCheck();
combinedAudioElement.playbackRate = currentPlaybackRate;
// 再生開始後も終了時間チェックを継続
checkEndTime();
}).catch(error => {
console.error('音声再生エラー:', error);
isPlaying = false;
if (playPauseBtn) playPauseBtn.textContent = '▶';
});
}
} catch (error) {
console.error('メディア再生エラー:', error);
isPlaying = false;
if (playPauseBtn) playPauseBtn.textContent = '▶';
}
}
function pauseMedia() {
try {
if (!combinedAudioElement) return;
combinedAudioElement.pause();
isPlaying = false;
if (playPauseBtn) playPauseBtn.textContent = '▶';
stopSyncCheck();
} catch (error) {
console.error('メディア一時停止エラー:', error);
}
}
// プログレスバークリックでシーク
if (progressContainer) {
progressContainer.addEventListener('click', function(e) {
if (!combinedAudioElement || !combinedAudioElement.duration) return;
const rect = this.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
const seekTime = pos * combinedAudioElement.duration;
seekMedia(seekTime);
});
}
// 指定した時間にシーク (改良版)
function seekMedia(time) {
try {
if (!combinedAudioElement) return;
const duration = combinedAudioElement.duration || audioDuration;
const startTime = parseFloat(startTimeInput.value) || 0;
const endTime = parseFloat(endTimeInput.value) || duration;
const seekTime = Math.max(startTime, Math.min(time, endTime));
combinedAudioElement.currentTime = seekTime;
if (isPlaying) {
combinedAudioElement.play().catch(e => console.error('音声再生エラー:', e));
}
} catch (error) {
console.error('メディアシークエラー:', error);
}
}
// プログレスバー上でマウス移動時に時間を表示
if (progressContainer) {
progressContainer.addEventListener('mousemove', function(e) {
if (!combinedAudioElement || !combinedAudioElement.duration || !progressTime) return;
const rect = this.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
const hoverTime = pos * combinedAudioElement.duration;
const minutes = Math.floor(hoverTime / 60);
const seconds = Math.floor(hoverTime % 60);
const milliseconds = Math.floor((hoverTime % 1) * 100);
progressTime.textContent = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(milliseconds).padStart(2, '0')}`;
progressTime.style.display = 'block';
progressTime.style.left = `${pos * 100}%`;
});
progressContainer.addEventListener('mouseleave', function() {
if (progressTime) {
progressTime.style.display = 'none';
}
});
}
if (volumeSlider) {
volumeSlider.addEventListener('input', function() {
if (!isAudioCombined) return;
lastVolume = parseFloat(this.value);
applyVolume();
});
}
if (volumeBtn) {
volumeBtn.addEventListener('click', function() {
if (!isAudioCombined) return;
if (combinedAudioElement.volume > 0) {
lastVolume = parseFloat(volumeSlider.value);
volumeSlider.value = 0;
} else {
volumeSlider.value = lastVolume;
}
applyVolume();
});
}
// 音量アイコンを更新
function updateVolumeIcon() {
if (!combinedAudioElement || !isAudioCombined || !volumeBtn) return;
if (combinedAudioElement.volume === 0) {
volumeBtn.textContent = '🔇';
} else if (combinedAudioElement.volume < 0.3) {
volumeBtn.textContent = '🔈';
} else {
volumeBtn.textContent = '🔊';
}
}
// 再生速度スライダー (動画プレイヤー)
if (speedSlider) {
speedSlider.addEventListener('input', function() {
if (!isAudioCombined) return;
const speed = parseFloat(this.value);
if (speedValue) speedValue.textContent = speed.toFixed(2) + 'x';
if (playbackSpeedSlider) playbackSpeedSlider.value = speed;
if (playbackSpeedValue) playbackSpeedValue.textContent = speed.toFixed(2) + 'x';
updatePlaybackRate(speed);
});
}
// 再生速度スライダー (設定メニュー)
if (playbackSpeedSlider) {
playbackSpeedSlider.addEventListener('input', function() {
if (!isAudioCombined) return;
const speed = parseFloat(this.value);
if (playbackSpeedValue) playbackSpeedValue.textContent = speed.toFixed(2) + 'x';
if (speedSlider) speedSlider.value = speed;
if (speedValue) speedValue.textContent = speed.toFixed(2) + 'x';
updatePlaybackRate(speed);
});
}
// テンポ入力による再生速度更新
if (tempoInput) {
tempoInput.addEventListener('input', function() {
const tempo = parseFloat(this.value);
const baseTempo = isTMode ? 66 : 92;
const speed = tempo / baseTempo;
const clampedSpeed = Math.max(0.001, Math.min(5.0, speed));
if (playbackSpeedSlider) playbackSpeedSlider.value = clampedSpeed;
if (playbackSpeedValue) playbackSpeedValue.textContent = clampedSpeed.toFixed(2) + 'x';
if (speedSlider) speedSlider.value = clampedSpeed;
if (speedValue) speedValue.textContent = clampedSpeed.toFixed(2) + 'x';
if (tempoSpeedValue) tempoSpeedValue.textContent = clampedSpeed.toFixed(2) + 'x';
updatePlaybackRate(clampedSpeed);
});
}
function updatePlaybackRate(speed) {
if (!isAudioCombined || !combinedAudioElement) return;
currentPlaybackRate = speed;
combinedAudioElement.playbackRate = speed;
// 合成音声のピッチ保持を設定
combinedAudioElement.preservesPitch = true;
combinedAudioElement.mozPreservesPitch = true;
combinedAudioElement.webkitPreservesPitch = true;
}
// ボリュームスライダーのイベント
audioSliders.forEach((slider, index) => {
slider.addEventListener('input', function() {
const value = parseFloat(this.value);
if (volumeValues[index]) {
volumeValues[index].textContent = value.toFixed(2);
}
const percent = value * 100;
this.style.backgroundSize = `${percent}% 100%`;
});
});
// FFmpeg音量スライダーのイベント
if (ffmpegVolumeSlider) {
ffmpegVolumeSlider.addEventListener('input', function() {
const value = parseFloat(this.value);
if (ffmpegVolumeValue) {
ffmpegVolumeValue.textContent = value.toFixed(2);
}
const percent = (value / 20) * 100;
this.style.backgroundSize = `${percent}% 100%`;
});
}
if (globalVolumeSlider) {
globalVolumeSlider.addEventListener('input', function() {
const value = parseFloat(this.value);
if (globalVolumeValue) globalVolumeValue.textContent = value.toFixed(2);
const percent = value * 100;
this.style.backgroundSize = `${percent}% 100%`;
applyVolume();
});
}
// ループ設定変更時
if (loopCheckbox) {
loopCheckbox.addEventListener('change', function() {
// 合成音声ではループは音声に依存する
});
}
// 現在の秒数を開始時間に設定
if (setStartTimeBtn) {
setStartTimeBtn.addEventListener('click', function() {
if (combinedAudioElement) {
startTimeInput.value = combinedAudioElement.currentTime.toFixed(2);
updateProgressMarkers();
}
});
}
// 現在の秒数を終了時間に設定
if (setEndTimeBtn) {
setEndTimeBtn.addEventListener('click', function() {
if (combinedAudioElement) {
endTimeInput.value = combinedAudioElement.currentTime.toFixed(2);
updateProgressMarkers();
}
});
}
// 終了時間を音声の長さにリセット
if (resetEndTimeBtn) {
resetEndTimeBtn.addEventListener('click', function() {
if (combinedAudioElement) {
endTimeInput.value = combinedAudioElement.duration.toFixed(2);
updateProgressMarkers();
}
});
}
// プログレスバーのマーカーを更新
function updateProgressMarkers() {
if (!combinedAudioElement || !isAudioCombined) return;
const duration = combinedAudioElement.duration || 0;
const startTime = parseFloat(startTimeInput.value) || 0;
const endTime = parseFloat(endTimeInput.value) || duration;
if (duration > 0) {
if (startTime > 0 && startMarker) {
startMarker.style.left = `${(startTime / duration) * 100}%`;
startMarker.style.display = 'block';
} else if (startMarker) {
startMarker.style.display = 'none';
}
if (endTime < duration && endMarker) {
endMarker.style.left = `${(endTime / duration) * 100}%`;
endMarker.style.display = 'block';
} else if (endMarker) {
endMarker.style.display = 'none';
}
}
}
// 開始/終了時間変更時にマーカーを更新
if (startTimeInput) {
startTimeInput.addEventListener('input', updateProgressMarkers);
}
if (endTimeInput) {
endTimeInput.addEventListener('input', updateProgressMarkers);
}
// 合成ボタンクリック
if (combineButton) {
combineButton.addEventListener('click', combineAudio);
}
// プレビューボタンクリック
if (previewButton) {
previewButton.addEventListener('click', togglePreview);
}
// 同期ステータスを閉じる
if (syncStatusClose) {
syncStatusClose.addEventListener('click', function() {
if (syncStatus) {
syncStatus.style.display = 'none';
}
});
}
// 初期化
loadAudioFiles();
safeUpdateVolumeIcon();
if (volumeSlider) volumeSlider.value = 1;
// スライダーの背景を初期化
function initSliderBackgrounds() {
const sliders = [
volumeSlider,
speedSlider,
globalVolumeSlider,
playbackSpeedSlider,
ffmpegVolumeSlider,
...audioSliders
].filter(slider => slider !== null);
sliders.forEach(slider => {
if (slider) {
slider.style.backgroundImage = 'linear-gradient(#64ffda, #64ffda)';
slider.style.backgroundRepeat = 'no-repeat';
if (slider === globalVolumeSlider) {
const percent = slider.value * 100;
slider.style.backgroundSize = `${percent}% 100%`;
if (globalVolumeValue) globalVolumeValue.textContent = slider.value;
} else if (slider === ffmpegVolumeSlider) {
const percent = (slider.value / 2) * 100;
slider.style.backgroundSize = `${percent}% 100%`;
if (ffmpegVolumeValue) ffmpegVolumeValue.textContent = slider.value;
} else {
slider.style.backgroundSize = `${slider.value * 100}% 100%`;
}
}
});
}
// updateVolumeIconの安全な呼び出し
function safeUpdateVolumeIcon() {
if (combinedAudioElement && isAudioCombined) {
updateVolumeIcon();
} else if (volumeBtn) {
volumeBtn.textContent = '🔊';
}
}
initSliderBackgrounds();
safeUpdateVolumeIcon();
startSyncCheck(); // 同期チェックを開始
// 初期テンポ設定
if (tempoInput) {
tempoInput.value = isTMode ? 66 : 92;
tempoInput.dispatchEvent(new Event('input'));
}
// プレイ/ポーズボタンのイベントリスナー
if (playPauseBtn) {
playPauseBtn.addEventListener('click', togglePlayPause);
}
});
// タイムマーカー関連のコードを修正
document.addEventListener('DOMContentLoaded', function() {
// まず必要なDOM要素を取得
const startMarker = document.getElementById('start-marker');
const endMarker = document.getElementById('end-marker');
const startTimeInput = document.getElementById('start-time');
const endTimeInput = document.getElementById('end-time');
// URLパラメータをチェック
const urlParams = new URLSearchParams(window.location.search);
const isTMode = urlParams.has('mode') && urlParams.get('mode') === 't';
// タイムマーカーデータ
const timeMarkers = isTMode ? [{
label: '①[1番]',
time: 15.2
}, {
label: '②[1番]',
time: 43.8
}, {
label: '①[2番]',
time: 79.0
}, {
label: '②[2番]',
time: 108.0
}, {
label: '③',
time: 137.25
}, {
label: '④',
time: 162.55
}, {
label: '⑤',
time: 191.65
}] : [{
label: 'A',
time: 8.35
}, {
label: 'B',
time: 72.1
}, {
label: 'C',
time: 98.67
}, {
label: 'D',
time: 155.83
}, {
label: 'E',
time: 192.55
}];
// タイムマーカーを表示
const timeMarkersContainer = document.getElementById('time-markers-container');
if (timeMarkersContainer) {
timeMarkers.forEach(marker => {
const markerElement = document.createElement('div');
markerElement.className = 'time-marker';
markerElement.innerHTML = `<b>${marker.label}</b>(${marker.time}秒)`;
markerElement.dataset.time = marker.time;
// ドラッグ開始
markerElement.addEventListener('dragstart', function(e) {
e.dataTransfer.setData('text/plain', marker.time);
this.classList.add('dragging');
});
// ドラッグ終了
markerElement.addEventListener('dragend', function() {
this.classList.remove('dragging');
});
// ドラッグ可能に設定
markerElement.draggable = true;
// クリックで開始時間に設定
markerElement.addEventListener('click', function() {
if (startTimeInput) {
startTimeInput.value = marker.time;
// グローバル関数を呼び出す
if (typeof updateProgressMarkers === 'function') {
updateProgressMarkers();
}
}
});
timeMarkersContainer.appendChild(markerElement);
});
}
// ドロップターゲットの設定
[startTimeInput, endTimeInput].forEach(input => {
if (!input) return;
// ドラッグオーバー
input.addEventListener('dragover', function(e) {
e.preventDefault();
this.style.backgroundColor = 'rgba(100, 255, 218, 0.2)';
});
// ドラッグリーブ
input.addEventListener('dragleave', function() {
this.style.backgroundColor = '';
});
// ドロップ
input.addEventListener('drop', function(e) {
e.preventDefault();
this.style.backgroundColor = '';
const time = parseFloat(e.dataTransfer.getData('text/plain'));
if (!isNaN(time)) {
this.value = time;
// グローバル関数を呼び出す
if (typeof updateProgressMarkers === 'function') {
updateProgressMarkers();
}
}
});
});
// 入力ボックスの変更時にマーカーを更新
if (startTimeInput) {
startTimeInput.addEventListener('input', function() {
if (typeof updateProgressMarkers === 'function') {
updateProgressMarkers();
}
});
}
if (endTimeInput) {
endTimeInput.addEventListener('input', function() {
if (typeof updateProgressMarkers === 'function') {
updateProgressMarkers();
}
});
}
});
// タイトル設定
document.addEventListener('DOMContentLoaded', () => {
const urlParams = new URLSearchParams(window.location.search);
const titleElement = document.getElementById('title-name');
if (titleElement) {
titleElement.textContent = urlParams.get('mode') === 't' ? "地球星歌" : "時の旅人";
}
});
</script>
</body>
</html>