Prod hardening: one-game-per-player, selectable music, bargevasat.ir config
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 7m47s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 1s

- One running game per player: server rejects a 2nd matchmake while in a live
  room (re-syncs the existing game); client guards Home vs-computer + Lobby
  random/create — resumes the running match + notifies instead of starting another
  (game-store hasActiveMatch()).
- Background music is now selectable: santoor (سنتی, calm Persian loop) and
  playful (bouncy UNO-like) — sound.ts TRACKS + setMusicTrack (persisted),
  sound-store musicTrack, picker in Profile → Audio. i18n added.
- Production config for bargevasat.ir (prepare-only; no live deploy):
  appsettings.Production.example (CORS + ZarinPal + IAB to the domain),
  docker-compose.caddy.yml + Caddyfile (auto-HTTPS reverse proxy
  bargevasat.ir→web, api.bargevasat.ir→server), ENV_FILE PRODUCTION block,
  PRODUCTION.md go-live + Cafe Bazaar publish/IAB checklist. Fixed IAB package
  name to match Capacitor appId (com.bargevasat.app).

Verified: tsc + next build + dotnet build all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-06 23:05:52 +03:30
parent 265d878f22
commit e49df07c0f
13 changed files with 268 additions and 17 deletions
+11
View File
@@ -0,0 +1,11 @@
# Caddy reverse proxy for production (bargevasat.ir). Auto HTTPS via Let's Encrypt.
bargevasat.ir, www.bargevasat.ir {
encode zstd gzip
reverse_proxy web:80
}
api.bargevasat.ir {
encode zstd gzip
# SignalR (WebSockets) proxies transparently through Caddy.
reverse_proxy server:5005
}
+57
View File
@@ -0,0 +1,57 @@
# Production go-live — bargevasat.ir + Cafe Bazaar
Companion to `HANDOFF.md` / `DEPLOY.md`. Domain: **bargevasat.ir** (web) +
**api.bargevasat.ir** (.NET SignalR API). Android via **Cafe Bazaar**.
## 1. DNS + firewall (you do this)
- A-records → your server IP: `bargevasat.ir`, `www.bargevasat.ir`, `api.bargevasat.ir`.
- Open ports **80** + **443** (`ufw allow 80 && ufw allow 443`).
## 2. Production env (Gitea `ENV_FILE` secret)
Use the **PRODUCTION block** in `deploy/ENV_FILE.example`:
- `NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir` (baked at web build → needs a CI rebuild to change)
- `CORS_ORIGINS=https://bargevasat.ir,https://www.bargevasat.ir`
- `JWT_KEY` = `openssl rand -hex 32`, strong `POSTGRES_PASSWORD`
- ZarinPal **live**: `ZARINPAL_SANDBOX=false`, live merchant id, callback `https://api.bargevasat.ir/api/coins/pay/callback`, return `https://bargevasat.ir`
- ZarinPal panel: register the callback domain.
## 3. Deploy with HTTPS (Caddy)
The deploy job (or you, on the server) runs the stack **with the Caddy overlay**:
```bash
docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
```
Caddy auto-provisions Let's Encrypt certs and proxies `bargevasat.ir → web`,
`api.bargevasat.ir → server`. SignalR WebSockets pass through transparently.
(To wire this into CI, add `-f docker-compose.caddy.yml` to the deploy job's
compose commands once DNS resolves.)
## 4. Database (Supabase or the bundled Postgres)
- Bundled `db` service works for launch. For Supabase: set `Database__Provider=postgres`
+ the Supabase `ConnectionStrings__Default`, and **generate EF migrations** first
(`HANDOFF.md` §5.1) so the server runs `Migrate()` instead of `EnsureCreated()`.
- **Back up before every deploy** (the deploy job already `pg_dump`s).
## 5. Cafe Bazaar (Android) publish
1. **Build a signed release APK/AAB**`NEXT_PUBLIC_STORE=bazaar`,
`NEXT_PUBLIC_APP_PACKAGE=com.bargevasat.app`, `NEXT_PUBLIC_USE_SERVER=1`,
`NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir`, then `npm run cap:sync` +
build in Android Studio / gradle (see `ANDROID.md`). App id **com.bargevasat.app**.
2. Upload to **pardakht.cafebazaar.ir**, fill the listing (icon, screenshots, fa
description), submit for review.
3. **In-app billing (after approval):** in the Bazaar dev panel create the coin
SKUs (`p1``p4`, matching `ProfileService.Packs`), create the **Pardakht API**
OAuth client, do the one-time consent to get a **refresh token**, and put
`IAB_BAZAAR_CLIENT_ID/SECRET/REFRESH_TOKEN` (+ `IAB_PACKAGE_NAME=com.bargevasat.app`)
into `ENV_FILE`; set `IAB_ALLOW_UNVERIFIED=false`. The web client deep-links
`bazaar://in_app?...` and the server verifies the returned `purchaseToken`
before crediting (see `HANDOFF.md` §5.3 / `src/lib/storeBilling.ts`).
4. Set `Zarinpal__Sandbox=false` only for the **web/PWA** payment path; the
**store build uses IAB, not ZarinPal** (store policy).
## 6. Pre-launch hardening checklist
- [ ] `JWT_KEY` is a real 32+ char secret (compose `${JWT_KEY:?}` fails if unset).
- [ ] `IAB_ALLOW_UNVERIFIED=false`, `ZARINPAL_SANDBOX=false`.
- [ ] CORS = the real domains only (no localhost).
- [ ] DB backups confirmed (`/opt/hokm-backups`), volumes named (no orphan data — see DEPLOY.md incident rules).
- [ ] CI green: tsc + next build + dotnet build + Hokm.Sim.
- [ ] Smoke test on https://bargevasat.ir: OTP login, vs-AI game, ranked match, buy-coins redirect, friends/chat.
+19 -1
View File
@@ -44,7 +44,7 @@ ZARINPAL_CLIENT_RETURN_URL=http://localhost:1500
# Store in-app billing (Cafe Bazaar / Myket) — fill from the developer panels. # Store in-app billing (Cafe Bazaar / Myket) — fill from the developer panels.
# SKU == coin-pack id (p1/p2/…). Coins are credited only after the purchase # SKU == coin-pack id (p1/p2/…). Coins are credited only after the purchase
# token verifies server-to-server. # token verifies server-to-server.
IAB_PACKAGE_NAME=com.bargevasat.hokm IAB_PACKAGE_NAME=com.bargevasat.app
# Cafe Bazaar (pardakht dev API): create an OAuth client, do the one-time consent # Cafe Bazaar (pardakht dev API): create an OAuth client, do the one-time consent
# to obtain a refresh_token. https://pardakht.cafebazaar.ir/ # to obtain a refresh_token. https://pardakht.cafebazaar.ir/
IAB_BAZAAR_CLIENT_ID= IAB_BAZAAR_CLIENT_ID=
@@ -55,3 +55,21 @@ IAB_MYKET_ACCESS_TOKEN=
# DEV ONLY: credit purchases WITHOUT verifying (set true to test before you have # DEV ONLY: credit purchases WITHOUT verifying (set true to test before you have
# store creds). NEVER true in production. # store creds). NEVER true in production.
IAB_ALLOW_UNVERIFIED=false IAB_ALLOW_UNVERIFIED=false
# ──────────────────────────────────────────────────────────────────────────
# PRODUCTION (bargevasat.ir) — use these values instead of the local ones above,
# and deploy with the Caddy overlay (see PRODUCTION.md). DNS: bargevasat.ir,
# www, api → server IP; open 80/443. Caddy fronts TLS, so host ports are internal.
# ──────────────────────────────────────────────────────────────────────────
# WEB_PORT=1500
# API_PORT=1505
# DB_PORT=1510
# POSTGRES_PASSWORD=<strong>
# JWT_KEY=<openssl rand -hex 32>
# NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir # baked at web build time
# CORS_ORIGINS=https://bargevasat.ir,https://www.bargevasat.ir
# ZARINPAL_MERCHANT_ID=<live-merchant-id>
# ZARINPAL_SANDBOX=false
# ZARINPAL_CALLBACK_URL=https://api.bargevasat.ir/api/coins/pay/callback
# ZARINPAL_CLIENT_RETURN_URL=https://bargevasat.ir
# IAB_ALLOW_UNVERIFIED=false # fill the IAB_* creds from the Bazaar panel post-publish
+27
View File
@@ -0,0 +1,27 @@
# Production HTTPS overlay for bargevasat.ir.
# Caddy terminates TLS (auto Let's Encrypt) and reverse-proxies:
# https://bargevasat.ir → web (nginx static)
# https://api.bargevasat.ir → server (.NET SignalR)
# Run: docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
# (web/server are reached over the compose network by name; their host port
# publishes from docker-compose.yml are harmless but optional in prod.)
services:
caddy:
image: mirror.soroushasadi.com/caddy:2-alpine
container_name: hokm-caddy
restart: unless-stopped
depends_on:
- web
- server
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- hokm_caddy_data:/data
- hokm_caddy_config:/config
volumes:
hokm_caddy_data:
hokm_caddy_config:
@@ -47,6 +47,14 @@ public sealed class GameManager
public void StartMatchmaking(Player p) public void StartMatchmaking(Player p)
{ {
// One running game per player: if already in a live match, re-sync them to
// it (re-broadcasts current state) instead of starting a second game.
if (RoomOf(p.UserId) is { } existing)
{
existing.SetConnected(p.UserId, true);
return;
}
// Pro players skip the queue entirely. // Pro players skip the queue entirely.
if (p.Plan == "pro") if (p.Plan == "pro")
{ {
@@ -1,5 +1,5 @@
{ {
"// note": "Copy to appsettings.Production.json and fill secrets. Run with ASPNETCORE_ENVIRONMENT=Production.", "// note": "Copy to appsettings.Production.json and fill secrets. Run with ASPNETCORE_ENVIRONMENT=Production. (The Docker deploy uses env vars from ENV_FILE instead — this file is for a bare-metal run.)",
"Jwt": { "Jwt": {
"Key": "CHANGE-ME-to-a-long-random-secret-32+chars", "Key": "CHANGE-ME-to-a-long-random-secret-32+chars",
"Issuer": "hokm", "Issuer": "hokm",
@@ -12,10 +12,27 @@
"// Supabase": "Project Settings → Database → Connection string (use the pooled 6543 or direct 5432)", "// Supabase": "Project Settings → Database → Connection string (use the pooled 6543 or direct 5432)",
"Default": "Host=db.<project>.supabase.co;Port=5432;Database=postgres;Username=postgres;Password=<password>;SSL Mode=Require;Trust Server Certificate=true" "Default": "Host=db.<project>.supabase.co;Port=5432;Database=postgres;Username=postgres;Password=<password>;SSL Mode=Require;Trust Server Certificate=true"
}, },
"Cors": {
"Origins": "https://bargevasat.ir,https://www.bargevasat.ir"
},
"Zarinpal": { "Zarinpal": {
"MerchantId": "<your-live-merchant-id>", "MerchantId": "<your-live-merchant-id>",
"Sandbox": false, "Sandbox": false,
"CallbackUrl": "https://api.yourdomain.com/api/coins/pay/callback", "CallbackUrl": "https://api.bargevasat.ir/api/coins/pay/callback",
"ClientReturnUrl": "https://yourdomain.com" "ClientReturnUrl": "https://bargevasat.ir"
},
"Iab": {
"// note": "Cafe Bazaar / Myket in-app purchase. Fill after publishing & getting store creds.",
"AllowUnverified": false,
"Bazaar": {
"PackageName": "com.bargevasat.app",
"ClientId": "<bazaar-client-id>",
"ClientSecret": "<bazaar-client-secret>",
"RefreshToken": "<bazaar-refresh-token>"
},
"Myket": {
"PackageName": "com.bargevasat.app",
"AccessToken": "<myket-access-token>"
}
} }
} }
+16 -1
View File
@@ -16,7 +16,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Zap } from "lucide-react"; import { Zap } from "lucide-react";
import { useGameStore } from "@/lib/game-store"; import { useGameStore, hasActiveMatch } from "@/lib/game-store";
import { pushNotification } from "@/lib/notification-store";
import { useSessionStore } from "@/lib/session-store"; import { useSessionStore } from "@/lib/session-store";
import { useUIStore, type Screen } from "@/lib/ui-store"; import { useUIStore, type Screen } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
@@ -45,6 +46,20 @@ export function HomeScreen() {
const [speed, setSpeed] = useState(false); const [speed, setSpeed] = useState(false);
const playVsComputer = () => { const playVsComputer = () => {
// One game at a time: resume the running match instead of starting a new one.
if (hasActiveMatch()) {
useGameStore.getState().resume();
goGame("home");
pushNotification({
kind: "system",
titleFa: "بازی در جریان",
titleEn: "Game in progress",
bodyFa: "ابتدا بازی فعلی را تمام کنید یا تسلیم شوید.",
bodyEn: "Finish or forfeit your current game first.",
icon: "🎮",
});
return;
}
const you = profile?.displayName || t("seat.you"); const you = profile?.displayName || t("seat.you");
newMatch({ newMatch({
names: [you, "آرش", "کیان", "نیلوفر"], names: [you, "آرش", "کیان", "نیلوفر"],
@@ -9,9 +9,27 @@ import { MATCH_LEAGUES, leagueById } from "@/lib/online/gamification";
import { useOnlineStore } from "@/lib/online-store"; import { useOnlineStore } from "@/lib/online-store";
import { useSessionStore } from "@/lib/session-store"; import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store"; import { useUIStore } from "@/lib/ui-store";
import { useGameStore, hasActiveMatch } from "@/lib/game-store";
import { pushNotification } from "@/lib/notification-store";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
/** Block starting a 2nd game while one is running — resume it instead. */
function guardActiveMatch(): boolean {
if (!hasActiveMatch()) return false;
useGameStore.getState().resume();
useUIStore.getState().goGame("online");
pushNotification({
kind: "system",
titleFa: "بازی در جریان",
titleEn: "Game in progress",
bodyFa: "ابتدا بازی فعلی را تمام کنید یا تسلیم شوید.",
bodyEn: "Finish or forfeit your current game first.",
icon: "🎮",
});
return true;
}
export function OnlineLobbyScreen() { export function OnlineLobbyScreen() {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const createRoom = useOnlineStore((s) => s.createRoom); const createRoom = useOnlineStore((s) => s.createRoom);
@@ -27,12 +45,14 @@ export function OnlineLobbyScreen() {
// Private rooms with friends are free. // Private rooms with friends are free.
const onCreate = async () => { const onCreate = async () => {
if (guardActiveMatch()) return;
await createRoom({ targetScore: 7, stake: 0, ranked: false }); await createRoom({ targetScore: 7, stake: 0, ranked: false });
go("room"); go("room");
}; };
// Ranked random always costs the entry (you stake it). // Ranked random always costs the entry (you stake it).
const onRandom = async () => { const onRandom = async () => {
if (guardActiveMatch()) return;
if (lockedLeague) return; if (lockedLeague) return;
if (coins < entry) { if (coins < entry) {
go("buycoins"); go("buycoins");
+23 -1
View File
@@ -469,12 +469,34 @@ function SocialSettings() {
function SoundSettings() { function SoundSettings() {
const { t } = useI18n(); const { t } = useI18n();
const { sfx, music, toggleSfx, toggleMusic } = useSoundStore(); const { sfx, music, musicTrack, toggleSfx, toggleMusic, setMusicTrack } = useSoundStore();
const tracks = [
{ id: "santoor" as const, label: t("settings.trackSantoor") },
{ id: "playful" as const, label: t("settings.trackPlayful") },
];
return ( return (
<div className="glass rounded-2xl p-4 mt-4"> <div className="glass rounded-2xl p-4 mt-4">
<h3 className="text-sm font-bold text-cream/80 mb-2">{t("settings.audio")}</h3> <h3 className="text-sm font-bold text-cream/80 mb-2">{t("settings.audio")}</h3>
<ToggleRow icon={<Volume2 className="size-4 text-gold-400" />} label={t("settings.sound")} on={sfx} onClick={toggleSfx} /> <ToggleRow icon={<Volume2 className="size-4 text-gold-400" />} label={t("settings.sound")} on={sfx} onClick={toggleSfx} />
<ToggleRow icon={<Music className="size-4 text-gold-400" />} label={t("settings.music")} on={music} onClick={toggleMusic} /> <ToggleRow icon={<Music className="size-4 text-gold-400" />} label={t("settings.music")} on={music} onClick={toggleMusic} />
{/* music style picker */}
<div className="mt-3">
<div className="text-[11px] text-cream/55 mb-1.5">{t("settings.musicStyle")}</div>
<div className="grid grid-cols-2 gap-2">
{tracks.map((tr) => (
<button
key={tr.id}
onClick={() => setMusicTrack(tr.id)}
className={cn(
"press-3d rounded-xl py-2.5 text-sm font-bold",
musicTrack === tr.id ? "btn-gold" : "bg-navy-900/70 gold-border text-cream/70"
)}
>
{tr.label}
</button>
))}
</div>
</div>
</div> </div>
); );
} }
+9
View File
@@ -572,3 +572,12 @@ export const useGameStore = create<GameStore>((set, get) => {
}, },
}; };
}); });
/**
* True when the player has a running match that hasn't finished — used to enforce
* "one game at a time": entry points should resume this instead of starting another.
*/
export function hasActiveMatch(): boolean {
const s = useGameStore.getState();
return s.started && s.game.phase !== "match-over";
}
+6
View File
@@ -321,6 +321,9 @@ const fa: Dict = {
"settings.audio": "تنظیمات صدا", "settings.audio": "تنظیمات صدا",
"settings.sound": "افکت صدا", "settings.sound": "افکت صدا",
"settings.music": "موسیقی پس‌زمینه", "settings.music": "موسیقی پس‌زمینه",
"settings.musicStyle": "سبک موسیقی",
"settings.trackSantoor": "سنتی (سنتور)",
"settings.trackPlayful": "شاد",
"profile.cardFront": "روی کارت", "profile.cardFront": "روی کارت",
"profile.cardBack": "پشت کارت", "profile.cardBack": "پشت کارت",
@@ -648,6 +651,9 @@ const en: Dict = {
"settings.audio": "Audio", "settings.audio": "Audio",
"settings.sound": "Sound effects", "settings.sound": "Sound effects",
"settings.music": "Background music", "settings.music": "Background music",
"settings.musicStyle": "Music style",
"settings.trackSantoor": "Traditional (Santoor)",
"settings.trackPlayful": "Playful",
"profile.cardFront": "Card front", "profile.cardFront": "Card front",
"profile.cardBack": "Card back", "profile.cardBack": "Card back",
+10 -1
View File
@@ -1,13 +1,15 @@
"use client"; "use client";
import { create } from "zustand"; import { create } from "zustand";
import { sound } from "./sound"; import { sound, type MusicTrack } from "./sound";
interface SoundStore { interface SoundStore {
sfx: boolean; sfx: boolean;
music: boolean; music: boolean;
musicTrack: MusicTrack;
toggleSfx: () => void; toggleSfx: () => void;
toggleMusic: () => void; toggleMusic: () => void;
setMusicTrack: (t: MusicTrack) => void;
/** Master mute: turns BOTH sfx and music off (or both back on). */ /** Master mute: turns BOTH sfx and music off (or both back on). */
toggleAll: () => void; toggleAll: () => void;
} }
@@ -15,6 +17,13 @@ interface SoundStore {
export const useSoundStore = create<SoundStore>((set, get) => ({ export const useSoundStore = create<SoundStore>((set, get) => ({
sfx: sound.sfxEnabled, sfx: sound.sfxEnabled,
music: sound.musicEnabled, music: sound.musicEnabled,
musicTrack: sound.musicTrack,
setMusicTrack: (t) => {
// Picking a track also turns music on so the choice is audible immediately.
sound.setMusicTrack(t);
sound.setMusicEnabled(true);
set({ musicTrack: t, music: true });
},
toggleSfx: () => { toggleSfx: () => {
const v = !get().sfx; const v = !get().sfx;
sound.setSfxEnabled(v); sound.setSfxEnabled(v);
+42 -10
View File
@@ -18,6 +18,9 @@ export type Sfx =
const LS_SFX = "hokm.sfx"; const LS_SFX = "hokm.sfx";
const LS_MUSIC = "hokm.music"; const LS_MUSIC = "hokm.music";
const LS_TRACK = "hokm.musicTrack";
export type MusicTrack = "santoor" | "playful";
function loadBool(key: string, def = true): boolean { function loadBool(key: string, def = true): boolean {
if (typeof window === "undefined") return def; if (typeof window === "undefined") return def;
@@ -34,6 +37,8 @@ class SoundManager {
sfxEnabled = loadBool(LS_SFX); sfxEnabled = loadBool(LS_SFX);
musicEnabled = loadBool(LS_MUSIC); musicEnabled = loadBool(LS_MUSIC);
musicTrack: MusicTrack =
(typeof window !== "undefined" && (localStorage.getItem(LS_TRACK) as MusicTrack)) || "santoor";
/** Must be called from a user gesture to unlock audio. */ /** Must be called from a user gesture to unlock audio. */
init() { init() {
@@ -70,6 +75,18 @@ class SoundManager {
} }
} }
/** Switch the background music style; restarts the loop if playing. */
setMusicTrack(track: MusicTrack) {
this.musicTrack = track;
if (typeof window !== "undefined") localStorage.setItem(LS_TRACK, track);
this.step = 0;
if (this.musicEnabled) {
this.stopMusic();
this.init();
this.startMusic();
}
}
private tone( private tone(
freq: number, freq: number,
start: number, start: number,
@@ -147,29 +164,44 @@ class SoundManager {
} }
} }
// Gentle ambient loop on a Persian-flavored scale (Dastgah-ish). // Two selectable loops:
private MUSIC = [293.66, 311.13, 392, 440, 466.16, 392, 311.13, 293.66]; // • santoor — calm Persian-flavored (Dastgah-ish) legato loop with fifth harmony.
// • playful — bouncy major-pentatonic staccato loop (UNO-like).
private TRACKS: Record<
MusicTrack,
{ notes: number[]; gap: number; type: OscillatorType; attack: number; dur: number; peak: number; fifth: boolean }
> = {
santoor: {
notes: [293.66, 311.13, 392, 440, 466.16, 392, 311.13, 293.66],
gap: 900, type: "sine", attack: 0.3, dur: 1.6, peak: 0.5, fifth: true,
},
playful: {
notes: [523.25, 659.25, 784, 659.25, 587.33, 698.46, 880, 698.46, 587.33, 523.25],
gap: 360, type: "triangle", attack: 0.02, dur: 0.34, peak: 0.4, fifth: false,
},
};
startMusic() { startMusic() {
if (!this.musicEnabled || this.musicTimer || !this.ctx || !this.musicGain) return; if (!this.musicEnabled || this.musicTimer || !this.ctx || !this.musicGain) return;
const playNote = () => { const playNote = () => {
if (!this.ctx || !this.musicGain) return; if (!this.ctx || !this.musicGain) return;
const freq = this.MUSIC[this.step % this.MUSIC.length]; const cfg = this.TRACKS[this.musicTrack];
const freq = cfg.notes[this.step % cfg.notes.length];
this.step++; this.step++;
const osc = this.ctx.createOscillator(); const osc = this.ctx.createOscillator();
const g = this.ctx.createGain(); const g = this.ctx.createGain();
const t = this.ctx.currentTime; const t = this.ctx.currentTime;
osc.type = "sine"; osc.type = cfg.type;
osc.frequency.value = freq; osc.frequency.value = freq;
g.gain.setValueAtTime(0.0001, t); g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.5, t + 0.3); g.gain.exponentialRampToValueAtTime(cfg.peak, t + cfg.attack);
g.gain.exponentialRampToValueAtTime(0.0001, t + 1.6); g.gain.exponentialRampToValueAtTime(0.0001, t + cfg.dur);
osc.connect(g); osc.connect(g);
g.connect(this.musicGain); g.connect(this.musicGain);
osc.start(t); osc.start(t);
osc.stop(t + 1.7); osc.stop(t + cfg.dur + 0.1);
// soft fifth harmony every other note // soft fifth harmony (santoor) every other note
if (this.step % 2 === 0) { if (cfg.fifth && this.step % 2 === 0) {
const o2 = this.ctx.createOscillator(); const o2 = this.ctx.createOscillator();
const g2 = this.ctx.createGain(); const g2 = this.ctx.createGain();
o2.type = "sine"; o2.type = "sine";
@@ -184,7 +216,7 @@ class SoundManager {
} }
}; };
playNote(); playNote();
this.musicTimer = setInterval(playNote, 900); this.musicTimer = setInterval(playNote, this.TRACKS[this.musicTrack].gap);
} }
stopMusic() { stopMusic() {